mototrack 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MotoTrack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,246 @@
1
+ Metadata-Version: 2.4
2
+ Name: mototrack
3
+ Version: 1.0.0
4
+ Summary: Python client for the MotoTrack fleet-tracking API: vehicles, live positions, trips, events, streaming and webhooks.
5
+ Author-email: MotoTrack <support@themototrack.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://themototrack.com
8
+ Project-URL: Documentation, https://themototrack.com/developers
9
+ Keywords: mototrack,gps,fleet,tracking,telematics,odoo,erp
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # MotoTrack Python SDK
21
+
22
+ Pull your fleet's live tracking from [MotoTrack](https://themototrack.com) into Odoo, Django, a cron job or any other Python system.
23
+
24
+ - One file, standard library only, Python 3.8+
25
+ - Vehicles, latest and historical positions, trips, events, geofences
26
+ - Push positions from your own devices
27
+ - Live Server-Sent Events stream with automatic reconnect
28
+ - Webhook signature verification
29
+ - Automatic retry on rate limits (429) and gateway errors (502/503/504)
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install mototrack
35
+ ```
36
+
37
+ Or copy `mototrack.py` into your project (it has no dependencies).
38
+
39
+ ## Get an API key
40
+
41
+ In MotoTrack, open **Settings, Developer API** and create a key. Keys look like `mtk_live_...` and carry scopes:
42
+
43
+ | Scope | Allows |
44
+ |----------|------------------------------------------|
45
+ | `read` | every `GET` endpoint |
46
+ | `stream` | the live event stream |
47
+ | `ingest` | `push_positions()` (sending positions) |
48
+
49
+ Keep the key on your server. Never ship it in a browser or mobile app.
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ from mototrack import MotoTrack, ApiError
55
+
56
+ mt = MotoTrack("mtk_live_...")
57
+
58
+ print(mt.me()["account"]["name"])
59
+
60
+ for row in mt.latest_positions():
61
+ pos = row["position"]
62
+ if pos:
63
+ print(row["plateNumber"], pos["lat"], pos["lng"], pos["speed"], "km/h")
64
+ ```
65
+
66
+ ## Reference
67
+
68
+ All methods return plain dicts and lists decoded from the API's JSON. Times are ISO 8601 UTC strings (`"2026-11-14T08:30:00.000Z"`); wherever a method takes a time you may pass a string or a `datetime` (naive datetimes are read as UTC). Speeds are km/h.
69
+
70
+ ### `MotoTrack(api_key, base_url=..., stream_url=..., timeout=30, max_retries=3)`
71
+
72
+ | Argument | Default |
73
+ |---------------|-----------------------------------------------|
74
+ | `base_url` | `https://api.themototrack.com/api/v1` |
75
+ | `stream_url` | `https://socket.themototrack.com/v1/stream` |
76
+ | `timeout` | seconds per REST call |
77
+ | `max_retries` | retries after 429, 502, 503, 504 or a network error; `0` disables |
78
+
79
+ `mt.rate_limit` holds the last rate-limit headers seen: `{"limit": 600, "remaining": 598, "reset": 1794655800}` (`reset` is unix seconds).
80
+
81
+ ### Account
82
+
83
+ | Method | Returns |
84
+ |--------|---------|
85
+ | `me()` | `{key: {id, name, prefix, scopes, createdAt}, account: {organizationId, name}, plan, limits: {requestsPerMinute, streamConnections}}` |
86
+
87
+ ### Vehicles
88
+
89
+ | Method | Returns |
90
+ |--------|---------|
91
+ | `vehicles(page=1, limit=50, search=None)` | `{data: [Vehicle], pagination: {page, limit, total, totalPages}}`, `limit` at most 200 |
92
+ | `all_vehicles(search=None, page_size=200)` | generator over every vehicle, fetching pages as needed |
93
+ | `vehicle(vehicle_id)` | `Vehicle` |
94
+
95
+ A **Vehicle** is `{id, plateNumber, type, make, model, year, color, fuelType, trackerId, driver: {id, name, phone} or None, status: {online, lastSeenAt, battery, externalVoltage}, position: Position or None, createdAt}`.
96
+
97
+ ### Positions
98
+
99
+ | Method | Returns |
100
+ |--------|---------|
101
+ | `latest_positions()` | `[{vehicleId, plateNumber, online, lastSeenAt, position}]`, the cheapest way to poll a fleet |
102
+ | `positions(vehicle_id, from_=None, to=None, limit=None, cursor=None)` | `{data: [Position], nextCursor, range: {from, to}}`; `from_` defaults to 24 h before `to`, `to` to now, `limit` at most 5000 |
103
+ | `all_positions(vehicle_id, from_=None, to=None, page_size=5000)` | generator over every position in the range, following `nextCursor` |
104
+
105
+ A **Position** is `{lat, lng, speed, heading, altitude, recordedAt}`.
106
+
107
+ ```python
108
+ from datetime import datetime, timedelta, timezone
109
+
110
+ end = datetime.now(timezone.utc)
111
+ for p in mt.all_positions(vehicle_id, from_=end - timedelta(days=7), to=end):
112
+ print(p["recordedAt"], p["lat"], p["lng"])
113
+ ```
114
+
115
+ ### Trips, events, geofences
116
+
117
+ | Method | Returns |
118
+ |--------|---------|
119
+ | `trips(vehicle_id, from_=None, to=None)` | `[Trip]`; default last 7 days, at most 31 days |
120
+ | `events(vehicle_id=None, type=None, from_=None, to=None, limit=None)` | `[{id, vehicleId, plateNumber, type, lat, lng, occurredAt}]`, `limit` at most 500 |
121
+ | `geofences()` | `[{id, name, shape, center, radiusMeters, polygon, createdAt}]` |
122
+ | `geofence_events(geofence_id, from_=None, to=None, limit=None)` | entries and exits for one geofence |
123
+
124
+ A **Trip** is `{id, vehicleId, startedAt, endedAt, durationSeconds, distanceKm, averageSpeed, maxSpeed, start: {lat, lng}, end: {lat, lng}}`.
125
+
126
+ ### Sending positions
127
+
128
+ `push_positions(positions)` needs a key with the `ingest` scope. Each item needs `vehicleId` or `trackerId`, plus `lat` and `lng`; `speed`, `heading`, `altitude`, `satellites`, `battery` and `recordedAt` are optional. Lists longer than 100 are split into several calls and the results merged.
129
+
130
+ ```python
131
+ result = mt.push_positions([
132
+ {"vehicleId": "3f2a9c1e-...", "lat": -1.9441, "lng": 30.0619, "speed": 42, "recordedAt": datetime.now(timezone.utc)},
133
+ {"trackerId": "864000000000000", "lat": -1.95, "lng": 30.06},
134
+ ])
135
+ # {"accepted": 1, "discarded": 0, "errors": [{"index": 1, "code": "NOT_FOUND", "message": "..."}]}
136
+ ```
137
+
138
+ A rejected item never raises. Check `errors`, whose `index` points into the list you passed.
139
+
140
+ ## Live stream
141
+
142
+ The stream needs a key with the `stream` scope. It sends a `ready` frame, then the current position of every vehicle, then live events as they happen.
143
+
144
+ ```python
145
+ def on_event(event):
146
+ if event["type"] == "vehicle.position":
147
+ d = event["data"]
148
+ print(event["vehicle"]["plateNumber"], d["lat"], d["lng"], d["speed"])
149
+ elif event["type"] == "geofence.entered":
150
+ print(event["vehicle"]["plateNumber"], "entered", event["data"]["geofenceName"])
151
+ # return False to stop listening
152
+
153
+ mt.stream(on_event, events=["vehicle.position", "geofence.entered"], vehicle_ids=None)
154
+ ```
155
+
156
+ Or as a generator (break out of the loop to stop):
157
+
158
+ ```python
159
+ for event in mt.iter_events(events=["vehicle.online", "vehicle.offline"]):
160
+ print(event["vehicle"]["plateNumber"], event["type"])
161
+ ```
162
+
163
+ | Event type | `data` |
164
+ |------------|--------|
165
+ | `ready` | (no `data`) the frame itself has `connectionId`, `vehicles: [{id, plateNumber}]`, `events` |
166
+ | `vehicle.position` | `lat, lng, speed, heading, altitude, battery, recordedAt` |
167
+ | `vehicle.online`, `vehicle.offline` | |
168
+ | `geofence.entered`, `geofence.exited` | `geofenceId, geofenceName, lat, lng` |
169
+ | `vehicle.overspeed.started`, `vehicle.overspeed.ended` | `speed, limit, lat, lng` |
170
+
171
+ Every event other than `ready` is `{id, type, occurredAt, vehicle: {id, plateNumber}, data}`. The `ready` frame always lists the whole fleet; the `vehicle_ids` filter applies to the events that follow.
172
+
173
+ **Reconnects.** After a dropped connection the SDK waits 5 s (or what the server suggests) and reconnects, receiving a fresh `ready` and current positions. `max_reconnects` (default 10) caps consecutive failures; pass `None` to retry forever. It never reconnects after `API_KEY_MISSING`, `API_KEY_INVALID`, `SCOPE_MISSING` or `SUBSCRIPTION_REQUIRED`; those raise `ApiError`. Each plan allows a fixed number of open streams (`me()["limits"]["streamConnections"]`).
174
+
175
+ ## Errors
176
+
177
+ Every failed call raises `ApiError` (also importable as `ApiException`):
178
+
179
+ ```python
180
+ try:
181
+ mt.vehicle("does-not-exist")
182
+ except ApiError as e:
183
+ print(e.status, e.code, e.message) # 404 NOT_FOUND Vehicle not found
184
+ ```
185
+
186
+ | Status | `code` | Meaning |
187
+ |--------|--------|---------|
188
+ | 400 | `VALIDATION_ERROR` | a parameter is wrong (for example a trip span over 31 days) |
189
+ | 401 | `API_KEY_MISSING`, `API_KEY_INVALID` | no key, or a revoked or mistyped one |
190
+ | 402 | `SUBSCRIPTION_REQUIRED` | the account has no active subscription |
191
+ | 403 | `SCOPE_MISSING` | the key lacks `read`, `stream` or `ingest` |
192
+ | 404 | `NOT_FOUND` | no such vehicle or geofence on this account |
193
+ | 429 | `RATE_LIMITED` | over the plan's requests per minute (retried automatically first) |
194
+ | 500 | `INTERNAL` | something broke on our side |
195
+ | 0 | `NETWORK_ERROR` | the API could not be reached (after retries) |
196
+
197
+ Retries: a 429 waits for `Retry-After`; 502, 503, 504 and network errors back off exponentially (1 s, 2 s, 4 s, ...). A `push_positions()` call that timed out is not resent, because the server may already have stored it.
198
+
199
+ ## Webhooks
200
+
201
+ MotoTrack can POST events to your server: online/offline, geofence and overspeed events, in the same `{id, type, occurredAt, vehicle, data}` shape as the stream (`vehicle.position` is stream-only, it would be far too chatty for webhooks). Each delivery carries `X-MotoTrack-Event`, `X-MotoTrack-Event-Id` and `X-MotoTrack-Signature: t=<unix seconds>,v1=<hex>`, where the hex is HMAC-SHA256 of `"<t>.<raw body>"` with your endpoint's signing secret.
202
+
203
+ `verify_webhook(raw_body, signature_header, secret, tolerance=300)` checks the signature in constant time, rejects deliveries older than `tolerance` seconds, and returns the decoded event. It needs no API key and is available as a module function or `MotoTrack.verify_webhook`.
204
+
205
+ ```python
206
+ import os
207
+ from flask import Flask, abort, request
208
+ from mototrack import WebhookVerificationError, verify_webhook
209
+
210
+ app = Flask(__name__)
211
+ SECRET = os.environ["MOTOTRACK_WEBHOOK_SECRET"]
212
+
213
+ @app.post("/mototrack/webhook")
214
+ def mototrack_webhook():
215
+ try:
216
+ event = verify_webhook(
217
+ request.get_data(), # the raw bytes, not request.json
218
+ request.headers.get("X-MotoTrack-Signature", ""),
219
+ SECRET,
220
+ )
221
+ except WebhookVerificationError:
222
+ abort(400)
223
+
224
+ # Deliveries can repeat; use the event ID to process each one once.
225
+ if already_processed(event["id"]):
226
+ return "", 200
227
+
228
+ if event["type"] == "geofence.entered":
229
+ print(event["vehicle"]["plateNumber"], "arrived at", event["data"]["geofenceName"])
230
+ return "", 200
231
+ ```
232
+
233
+ Answer with a 2xx quickly and do slow work in the background.
234
+
235
+ ## Examples
236
+
237
+ `examples/odoo_sync.py` keeps an ERP table in step with the fleet, either polling every 30 s or listening to the stream:
238
+
239
+ ```bash
240
+ MOTOTRACK_API_KEY=mtk_live_... python examples/odoo_sync.py poll
241
+ MOTOTRACK_API_KEY=mtk_live_... python examples/odoo_sync.py stream
242
+ ```
243
+
244
+ ## License
245
+
246
+ MIT
@@ -0,0 +1,227 @@
1
+ # MotoTrack Python SDK
2
+
3
+ Pull your fleet's live tracking from [MotoTrack](https://themototrack.com) into Odoo, Django, a cron job or any other Python system.
4
+
5
+ - One file, standard library only, Python 3.8+
6
+ - Vehicles, latest and historical positions, trips, events, geofences
7
+ - Push positions from your own devices
8
+ - Live Server-Sent Events stream with automatic reconnect
9
+ - Webhook signature verification
10
+ - Automatic retry on rate limits (429) and gateway errors (502/503/504)
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install mototrack
16
+ ```
17
+
18
+ Or copy `mototrack.py` into your project (it has no dependencies).
19
+
20
+ ## Get an API key
21
+
22
+ In MotoTrack, open **Settings, Developer API** and create a key. Keys look like `mtk_live_...` and carry scopes:
23
+
24
+ | Scope | Allows |
25
+ |----------|------------------------------------------|
26
+ | `read` | every `GET` endpoint |
27
+ | `stream` | the live event stream |
28
+ | `ingest` | `push_positions()` (sending positions) |
29
+
30
+ Keep the key on your server. Never ship it in a browser or mobile app.
31
+
32
+ ## Quick start
33
+
34
+ ```python
35
+ from mototrack import MotoTrack, ApiError
36
+
37
+ mt = MotoTrack("mtk_live_...")
38
+
39
+ print(mt.me()["account"]["name"])
40
+
41
+ for row in mt.latest_positions():
42
+ pos = row["position"]
43
+ if pos:
44
+ print(row["plateNumber"], pos["lat"], pos["lng"], pos["speed"], "km/h")
45
+ ```
46
+
47
+ ## Reference
48
+
49
+ All methods return plain dicts and lists decoded from the API's JSON. Times are ISO 8601 UTC strings (`"2026-11-14T08:30:00.000Z"`); wherever a method takes a time you may pass a string or a `datetime` (naive datetimes are read as UTC). Speeds are km/h.
50
+
51
+ ### `MotoTrack(api_key, base_url=..., stream_url=..., timeout=30, max_retries=3)`
52
+
53
+ | Argument | Default |
54
+ |---------------|-----------------------------------------------|
55
+ | `base_url` | `https://api.themototrack.com/api/v1` |
56
+ | `stream_url` | `https://socket.themototrack.com/v1/stream` |
57
+ | `timeout` | seconds per REST call |
58
+ | `max_retries` | retries after 429, 502, 503, 504 or a network error; `0` disables |
59
+
60
+ `mt.rate_limit` holds the last rate-limit headers seen: `{"limit": 600, "remaining": 598, "reset": 1794655800}` (`reset` is unix seconds).
61
+
62
+ ### Account
63
+
64
+ | Method | Returns |
65
+ |--------|---------|
66
+ | `me()` | `{key: {id, name, prefix, scopes, createdAt}, account: {organizationId, name}, plan, limits: {requestsPerMinute, streamConnections}}` |
67
+
68
+ ### Vehicles
69
+
70
+ | Method | Returns |
71
+ |--------|---------|
72
+ | `vehicles(page=1, limit=50, search=None)` | `{data: [Vehicle], pagination: {page, limit, total, totalPages}}`, `limit` at most 200 |
73
+ | `all_vehicles(search=None, page_size=200)` | generator over every vehicle, fetching pages as needed |
74
+ | `vehicle(vehicle_id)` | `Vehicle` |
75
+
76
+ A **Vehicle** is `{id, plateNumber, type, make, model, year, color, fuelType, trackerId, driver: {id, name, phone} or None, status: {online, lastSeenAt, battery, externalVoltage}, position: Position or None, createdAt}`.
77
+
78
+ ### Positions
79
+
80
+ | Method | Returns |
81
+ |--------|---------|
82
+ | `latest_positions()` | `[{vehicleId, plateNumber, online, lastSeenAt, position}]`, the cheapest way to poll a fleet |
83
+ | `positions(vehicle_id, from_=None, to=None, limit=None, cursor=None)` | `{data: [Position], nextCursor, range: {from, to}}`; `from_` defaults to 24 h before `to`, `to` to now, `limit` at most 5000 |
84
+ | `all_positions(vehicle_id, from_=None, to=None, page_size=5000)` | generator over every position in the range, following `nextCursor` |
85
+
86
+ A **Position** is `{lat, lng, speed, heading, altitude, recordedAt}`.
87
+
88
+ ```python
89
+ from datetime import datetime, timedelta, timezone
90
+
91
+ end = datetime.now(timezone.utc)
92
+ for p in mt.all_positions(vehicle_id, from_=end - timedelta(days=7), to=end):
93
+ print(p["recordedAt"], p["lat"], p["lng"])
94
+ ```
95
+
96
+ ### Trips, events, geofences
97
+
98
+ | Method | Returns |
99
+ |--------|---------|
100
+ | `trips(vehicle_id, from_=None, to=None)` | `[Trip]`; default last 7 days, at most 31 days |
101
+ | `events(vehicle_id=None, type=None, from_=None, to=None, limit=None)` | `[{id, vehicleId, plateNumber, type, lat, lng, occurredAt}]`, `limit` at most 500 |
102
+ | `geofences()` | `[{id, name, shape, center, radiusMeters, polygon, createdAt}]` |
103
+ | `geofence_events(geofence_id, from_=None, to=None, limit=None)` | entries and exits for one geofence |
104
+
105
+ A **Trip** is `{id, vehicleId, startedAt, endedAt, durationSeconds, distanceKm, averageSpeed, maxSpeed, start: {lat, lng}, end: {lat, lng}}`.
106
+
107
+ ### Sending positions
108
+
109
+ `push_positions(positions)` needs a key with the `ingest` scope. Each item needs `vehicleId` or `trackerId`, plus `lat` and `lng`; `speed`, `heading`, `altitude`, `satellites`, `battery` and `recordedAt` are optional. Lists longer than 100 are split into several calls and the results merged.
110
+
111
+ ```python
112
+ result = mt.push_positions([
113
+ {"vehicleId": "3f2a9c1e-...", "lat": -1.9441, "lng": 30.0619, "speed": 42, "recordedAt": datetime.now(timezone.utc)},
114
+ {"trackerId": "864000000000000", "lat": -1.95, "lng": 30.06},
115
+ ])
116
+ # {"accepted": 1, "discarded": 0, "errors": [{"index": 1, "code": "NOT_FOUND", "message": "..."}]}
117
+ ```
118
+
119
+ A rejected item never raises. Check `errors`, whose `index` points into the list you passed.
120
+
121
+ ## Live stream
122
+
123
+ The stream needs a key with the `stream` scope. It sends a `ready` frame, then the current position of every vehicle, then live events as they happen.
124
+
125
+ ```python
126
+ def on_event(event):
127
+ if event["type"] == "vehicle.position":
128
+ d = event["data"]
129
+ print(event["vehicle"]["plateNumber"], d["lat"], d["lng"], d["speed"])
130
+ elif event["type"] == "geofence.entered":
131
+ print(event["vehicle"]["plateNumber"], "entered", event["data"]["geofenceName"])
132
+ # return False to stop listening
133
+
134
+ mt.stream(on_event, events=["vehicle.position", "geofence.entered"], vehicle_ids=None)
135
+ ```
136
+
137
+ Or as a generator (break out of the loop to stop):
138
+
139
+ ```python
140
+ for event in mt.iter_events(events=["vehicle.online", "vehicle.offline"]):
141
+ print(event["vehicle"]["plateNumber"], event["type"])
142
+ ```
143
+
144
+ | Event type | `data` |
145
+ |------------|--------|
146
+ | `ready` | (no `data`) the frame itself has `connectionId`, `vehicles: [{id, plateNumber}]`, `events` |
147
+ | `vehicle.position` | `lat, lng, speed, heading, altitude, battery, recordedAt` |
148
+ | `vehicle.online`, `vehicle.offline` | |
149
+ | `geofence.entered`, `geofence.exited` | `geofenceId, geofenceName, lat, lng` |
150
+ | `vehicle.overspeed.started`, `vehicle.overspeed.ended` | `speed, limit, lat, lng` |
151
+
152
+ Every event other than `ready` is `{id, type, occurredAt, vehicle: {id, plateNumber}, data}`. The `ready` frame always lists the whole fleet; the `vehicle_ids` filter applies to the events that follow.
153
+
154
+ **Reconnects.** After a dropped connection the SDK waits 5 s (or what the server suggests) and reconnects, receiving a fresh `ready` and current positions. `max_reconnects` (default 10) caps consecutive failures; pass `None` to retry forever. It never reconnects after `API_KEY_MISSING`, `API_KEY_INVALID`, `SCOPE_MISSING` or `SUBSCRIPTION_REQUIRED`; those raise `ApiError`. Each plan allows a fixed number of open streams (`me()["limits"]["streamConnections"]`).
155
+
156
+ ## Errors
157
+
158
+ Every failed call raises `ApiError` (also importable as `ApiException`):
159
+
160
+ ```python
161
+ try:
162
+ mt.vehicle("does-not-exist")
163
+ except ApiError as e:
164
+ print(e.status, e.code, e.message) # 404 NOT_FOUND Vehicle not found
165
+ ```
166
+
167
+ | Status | `code` | Meaning |
168
+ |--------|--------|---------|
169
+ | 400 | `VALIDATION_ERROR` | a parameter is wrong (for example a trip span over 31 days) |
170
+ | 401 | `API_KEY_MISSING`, `API_KEY_INVALID` | no key, or a revoked or mistyped one |
171
+ | 402 | `SUBSCRIPTION_REQUIRED` | the account has no active subscription |
172
+ | 403 | `SCOPE_MISSING` | the key lacks `read`, `stream` or `ingest` |
173
+ | 404 | `NOT_FOUND` | no such vehicle or geofence on this account |
174
+ | 429 | `RATE_LIMITED` | over the plan's requests per minute (retried automatically first) |
175
+ | 500 | `INTERNAL` | something broke on our side |
176
+ | 0 | `NETWORK_ERROR` | the API could not be reached (after retries) |
177
+
178
+ Retries: a 429 waits for `Retry-After`; 502, 503, 504 and network errors back off exponentially (1 s, 2 s, 4 s, ...). A `push_positions()` call that timed out is not resent, because the server may already have stored it.
179
+
180
+ ## Webhooks
181
+
182
+ MotoTrack can POST events to your server: online/offline, geofence and overspeed events, in the same `{id, type, occurredAt, vehicle, data}` shape as the stream (`vehicle.position` is stream-only, it would be far too chatty for webhooks). Each delivery carries `X-MotoTrack-Event`, `X-MotoTrack-Event-Id` and `X-MotoTrack-Signature: t=<unix seconds>,v1=<hex>`, where the hex is HMAC-SHA256 of `"<t>.<raw body>"` with your endpoint's signing secret.
183
+
184
+ `verify_webhook(raw_body, signature_header, secret, tolerance=300)` checks the signature in constant time, rejects deliveries older than `tolerance` seconds, and returns the decoded event. It needs no API key and is available as a module function or `MotoTrack.verify_webhook`.
185
+
186
+ ```python
187
+ import os
188
+ from flask import Flask, abort, request
189
+ from mototrack import WebhookVerificationError, verify_webhook
190
+
191
+ app = Flask(__name__)
192
+ SECRET = os.environ["MOTOTRACK_WEBHOOK_SECRET"]
193
+
194
+ @app.post("/mototrack/webhook")
195
+ def mototrack_webhook():
196
+ try:
197
+ event = verify_webhook(
198
+ request.get_data(), # the raw bytes, not request.json
199
+ request.headers.get("X-MotoTrack-Signature", ""),
200
+ SECRET,
201
+ )
202
+ except WebhookVerificationError:
203
+ abort(400)
204
+
205
+ # Deliveries can repeat; use the event ID to process each one once.
206
+ if already_processed(event["id"]):
207
+ return "", 200
208
+
209
+ if event["type"] == "geofence.entered":
210
+ print(event["vehicle"]["plateNumber"], "arrived at", event["data"]["geofenceName"])
211
+ return "", 200
212
+ ```
213
+
214
+ Answer with a 2xx quickly and do slow work in the background.
215
+
216
+ ## Examples
217
+
218
+ `examples/odoo_sync.py` keeps an ERP table in step with the fleet, either polling every 30 s or listening to the stream:
219
+
220
+ ```bash
221
+ MOTOTRACK_API_KEY=mtk_live_... python examples/odoo_sync.py poll
222
+ MOTOTRACK_API_KEY=mtk_live_... python examples/odoo_sync.py stream
223
+ ```
224
+
225
+ ## License
226
+
227
+ MIT