Metadata-Version: 2.4
Name: taglogger-sdk
Version: 1.4.0
Summary: Official Python SDK for the TagLogger API, with a bundled client-side location-analytics toolkit.
Project-URL: Homepage, https://taglogger.com
Project-URL: Documentation, https://taglogger.com/docs/quickstart
Project-URL: SDK reference, https://taglogger.com/docs/sdks/python
Project-URL: API reference, https://taglogger.com/docs/api-reference/endpoints
Project-URL: Changelog, https://taglogger.com/docs/changelog
Project-URL: Troubleshooting, https://taglogger.com/docs/troubleshooting
Author: TagLogger
License: MIT
License-File: LICENSE
Keywords: airtag,api-client,asset-tracking,fleet-tracking,geofence,gps,location-analytics,sdk,taglogger,webhooks
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: mypy<2,>=1.0; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.1; extra == 'dev'
Description-Content-Type: text/markdown

# TagLogger Python SDK

Official Python SDK for the [TagLogger API](https://taglogger.com/docs/quickstart) — read your tags and their locations, sync your whole fleet incrementally, manage geofences, webhooks and share links, and verify incoming webhook deliveries.

It also bundles **`taglogger_sdk.analytics`**, a pure-math, client-side location-analytics toolkit (conditioning, stops/trips, dwell, clustering, GeoJSON/polyline shaping, battery projection) that runs entirely in-process over the location data the API returns — no extra services, no network calls.

- **Zero runtime dependencies.** Standard library only.
- **Typed.** Ships `py.typed`; full type hints on the public surface.
- **Synchronous** and thread-safe to reuse: a single client instance carries no mutable per-call state.
- **Python 3.9+.**

**Documentation:** [Quick start](https://taglogger.com/docs/quickstart) · [Python SDK reference](https://taglogger.com/docs/sdks/python) · [API reference](https://taglogger.com/docs/api-reference/endpoints) · [Webhooks](https://taglogger.com/docs/webhooks/overview) · [Changelog](https://taglogger.com/docs/changelog)

## Install

```bash
pip install taglogger-sdk
```

The distribution installs as `taglogger-sdk`; you import it as `taglogger_sdk`.

## Quick start

```python
import os

from taglogger_sdk import TagLoggerClient

client = TagLoggerClient(api_key=os.environ["TAGLOGGER_API_KEY"])

# One page of tags
page = client.tags.list(limit=50)
for tag in page["data"]:
    print(tag["id"], tag.get("name"))

# Or stream every tag, paging transparently
for tag in client.tags.iterate():
    print(tag["id"])

# Latest location for one tag
location = client.tags.location("tag-1")
print(location["data"], "recording paused:", location["recordingPaused"])
```

Get an API key from your TagLogger dashboard under **Settings → API Keys**; [the API keys guide](https://taglogger.com/docs/api-keys) covers what each scope grants. Keys are scoped, and each method below notes the scope it needs. [Authentication](https://taglogger.com/docs/authentication) covers the two header styles the client can send.

## Configuration

```python
client = TagLoggerClient(
    api_key=os.environ["TAGLOGGER_API_KEY"],  # never hardcode the key
    base_url="https://api.taglogger.com",  # override to target a staging deployment
    auth_header="bearer",                   # "bearer" (default) or "x-api-key"
    timeout_ms=30000,
    max_retries=2,                          # GET/DELETE retried on 5xx/network; any method retried on 429
    max_response_bytes=25 * 1024 * 1024,    # cap on buffered response bytes (default 25 MiB)
    default_headers={"X-Trace": "abc"},
    user_agent="my-app/1.0",
)
```

**Retries.** Transient failures are retried with randomized backoff: network errors and `5xx` on idempotent methods (`GET`/`DELETE`), and `429` on any method (honoring `Retry-After`). Non-idempotent writes are never silently retried after a transport failure.

**Idempotent creates.** `create()` on geofences, webhooks, share links and customer pages accepts an optional `idempotency_key=` keyword (a UUID per logical create is ideal). It is sent as the `Idempotency-Key` header, so if your own retry re-sends the same request after a dropped connection the API replays the original response instead of creating a duplicate. Reusing a key with a different body fails with `idempotency_key_reused`; a request with a key still in flight waits for it, then gets the same result. Keys are scoped to your workspace and expire after 24 hours. Geofence creates go further: the key fixes the new geofences' ids, so a retry with the same key and body, even after a timeout or a 503 `service_unavailable`, returns the same geofences and never writes one twice. If a geofence the key created was edited since, or the key comes back with a different body after 24 hours, the create fails with 409 `conflict` and writes nothing. Webhook creates keep no copy of the response: the key fixes the webhook's id, so a retry with the same key and body returns the same webhook, with the same `id` and `signingSecret`, while it is unchanged. Once the webhook has been updated, its secret rotated, or it has been deleted, a retry with that key fails with 409 `conflict` and writes nothing; after 24 hours, any create with the key fails with 409 `conflict` while its webhook exists. Send a new key with each new request.

```python
import uuid
geofence = client.geofences.create(body, idempotency_key=str(uuid.uuid4()))
```

## Resources

Every list endpoint returns a `{ "data": [...], "nextCursor": str | None }` envelope and has a matching `iterate*` helper that auto-paginates.

### Tags — `client.tags`

```python
client.tags.list(limit=None, cursor=None, search=None, label=None)  # read:tags
client.tags.iterate(limit=None, search=None, label=None) # read:tags
client.tags.get("tag-1")                           # read:tags
client.tags.update("tag-1", {                      # manage:tags
    "name": "Main warehouse AED",
    "labels": ["Coca-Cola", "Seattle"],
    "alertsPause": {"paused": True, "until": None},
    "recordingPause": {"paused": True, "until": 1790000000000, "reason": "Maintenance"},
})
client.tags.battery("tag-1")                       # read:tags
client.tags.location("tag-1")                      # read:locations -> { data, recordingPaused }
client.tags.history("tag-1", start=None, end=None, order="asc", limit=None, cursor=None)  # read:locations
client.tags.iterate_history("tag-1", start=None, end=None, order="asc", limit=None)       # read:locations
```

Tags returned by `list`, `iterate`, `get`, and `update` include
`lastLocation: None` unless the API key also has `read:locations`.
They also include the read-only `originalPhysicalLabel` recorded at original shipment or provisioning, workspace-defined
`labels`, and the system-provided beacon UUID as `hardwareId`. Display names
and labels can be updated; the original physical label and hardware ID cannot. None of
this metadata changes the hardware binding, ownership, or access.

`start` / `end` accept an ISO 8601 string or epoch milliseconds.
Location points expose optional `endTimestampMs` and `sampleCount` fields when a
point represents a window of identical sightings; a plain point uses its own
`timestampMs` and a count of `1`.

### Fleet — `client.fleet`

Incremental, whole-fleet location sync. Pass the high-water mark from your last sync as `since` (exclusive); omit it for a full sync.

```python
client.fleet.delta(since=None, limit=None, cursor=None)   # read:locations
client.fleet.iterate_delta(since=None, limit=None)        # read:locations -> Paginator[FleetDeltaItem]
```

### Geofences — `client.geofences`

```python
client.geofences.list(limit=None, cursor=None)     # read:geofences
client.geofences.iterate(limit=None)               # read:geofences
client.geofences.get("gf-1")                       # read:geofences
client.geofences.list_integrations()                # read:geofences -> redacted binding IDs
client.geofences.create({                          # manage:geofences; recipients also need read:locations
    "name": "Yard",
    "center": {"lat": 37.77, "lng": -122.42},
    "radiusMeters": 150,
    "targetTagIds": ["tag-1", "tag-2"],            # or ["all"] for all-tags keys
    "alerts": {
        "onEntry": True,
        "emailEnabled": True,
        "emailAddresses": ["dispatch@example.com"],
        "webhookTargetIds": ["existing-slack-integration-id"],
    },
})
client.geofences.update("gf-1", {"radiusMeters": 200})    # manage:geofences
client.geofences.delete("gf-1")                    # manage:geofences -> { id, deleted }

# Polygons and corridors (a band around a route). Every geofence has a "type".
client.geofences.create({
    "name": "Depot",
    "type": "polygon",
    "vertices": [{"lat": 37.770, "lng": -122.420}, {"lat": 37.770, "lng": -122.410},
                 {"lat": 37.776, "lng": -122.415}],
    "targetTagIds": ["all"],
})
client.geofences.create({
    "name": "Route 1",
    "type": "corridor",
    "path": [{"lat": 37.77, "lng": -122.42}, {"lat": 37.80, "lng": -122.27}],
    "widthMeters": 1000,                           # full band width, 500-20000, default 1000
    "targetTagIds": ["tag-1"],
})

# Up to 450 geofences in one all-or-nothing transaction, returned in request order.
client.geofences.bulk_create([depot, route], idempotency_key=None)   # manage:geofences

# One page of tags with their visits: entered, exited (None while still inside), duration.
client.geofences.visits("gf-1", from_=None, to=None, tag_ids=None, limit=None, cursor=None)
# read:geofences + read:locations -> {geofenceId, from, to, tags, nextCursor}
```

Shapes: a circle is `center` + `radiusMeters` (10 to 100000 m); a polygon is 3 to 500 `vertices` in order; a corridor is a `path` of 2 to 1000 points plus `widthMeters`. A polygon or corridor response carries `center` as its label point and `radiusMeters: None`. A shape error is 400 `invalid_geofence`: `err.details["reason"]` names it (for example `self_intersecting` or `too_small`), `err.details["index"]` is the point at fault when there is one, and in a bulk create `err.details["item"]` is the failing geofence. A create without `type` that sends `vertices` or `path` is refused with reason `invalid_type`, and `radiusMeters` must be a number, not text. The workspace comes from the API key; a create body or bulk item with `ownerId`, `userId` or `workspaceOwnerUid`, whatever its value, is 400 `invalid_request`. A bulk create whose geofences together take more than 4 MiB as JSON fails with 413 `payload_too_large`: send fewer per request. An update without `type` edits the current shape and must fit it: `center` and/or `radiusMeters` on a circle, `vertices` on a polygon, `path` and `widthMeters` together on a corridor. An update with `type` and that shape's fields replaces the shape whole. Anything else, such as `center` on a polygon or a field of another shape beside `type`, is 400 `invalid_geofence`.

Visits default to the 7 days before `to` (default now); the window is at most 400 days and the account needs the geofence time feature. They work the same for circles, polygons and corridors and come a page of tags at a time (`limit` default 10, at most 100). While `nextCursor` is not None, call again with it as `cursor` and the same `from_`, `to` and `tag_ids` (use the `from` and `to` the first page returned if you left them out); another window or `tag_ids` fails with 400 `invalid_cursor`. Tags come in tag id order, and a page can hold fewer than `limit` tags and still have a next page. A page whose window holds too many recorded locations fails with 422 `window_too_large`: choose a shorter window or a smaller `limit`. The API answers a page within 75 seconds (503 `service_unavailable` after that), so `visits` waits at least 90 seconds per attempt whatever `timeout_ms` is. `bulk_create` waits as long, since the API can take up to 75 seconds to save a bulk.

```python
cursor = None
while True:
    page = client.geofences.visits("gf-1", from_="2026-10-01T00:00:00Z", to="2026-10-08T00:00:00Z", cursor=cursor)
    for tag in page["tags"]:
        print(tag["tagId"], tag["visitCount"])
    cursor = page["nextCursor"]
    if cursor is None:
        break
```

**Temporarily unavailable.** A geofence write can fail with status 503 `service_unavailable`: the store did not answer and the write may have landed. Wait `err.retry_after_seconds` and send the request again. With an `idempotency_key`, retrying a create or bulk create after `service_unavailable` is safe: send the same key and body, and the retry returns the same geofences and never writes them twice. Without a key, list geofences before retrying a create. A bulk create is one transaction, all or nothing. The client retries a delete that fails with a 5xx by itself, but never a create or an update.

### Webhooks — `client.webhooks`

```python
client.webhooks.list(limit=None, cursor=None)      # manage:webhooks
client.webhooks.get("wh-1")                        # manage:webhooks
created = client.webhooks.create({                 # manage:webhooks + all-tags key; geofence.*/tag.moved also need read:locations
    "url": "https://example.com/hooks/taglogger",
    "events": ["geofence.entry", "geofence.exit", "tag.offline"],
})
secret = created["signingSecret"]                  # shown ONCE — store it now
client.webhooks.update("wh-1", {"active": False})  # manage:webhooks + all-tags key
client.webhooks.rotate_secret("wh-1")              # manage:webhooks + all-tags key -> new signingSecret (once)
client.webhooks.send_test("wh-1")                  # manage:webhooks
client.webhooks.deliveries("wh-1", limit=None, cursor=None)   # manage:webhooks
client.webhooks.iterate_deliveries("wh-1")         # manage:webhooks
client.webhooks.delete("wh-1")                     # manage:webhooks + all-tags key -> { id, deleted }
```

The event types are `geofence.entry`, `geofence.exit`, `geofence.dwell`, `tag.moved`, `tag.battery_low`, `tag.offline` and `security.event` (needs `read:security-events`; never in the default set, so subscribe to it by name).

### Share links — `client.share_links`

```python
client.share_links.list(tag_id=None, limit=None, cursor=None)  # read:share-links
client.share_links.iterate(tag_id=None, limit=None)            # read:share-links
client.share_links.get("sl-1")                                 # read:share-links
client.share_links.create({"tagId": "tag-1", "expiresInSeconds": 86400})  # manage:share-links
client.share_links.revoke("sl-1")                              # manage:share-links -> { id, revoked, status }
```

### Customer Pages — `client.customer_pages`

```python
client.customer_pages.list(limit=None, cursor=None)             # read:customer-pages
client.customer_pages.get("page-1")                            # read:customer-pages
client.customer_pages.create({                                  # manage:customer-pages
    "pageName": "Operations",
    "tagIds": ["tag-1", "tag-2"],
    "expiresAt": None,
    "features": {"liveMap": True, "zones": True, "statusTable": True, "zoneActivity": True, "locationHistory": False, "labels": False},
})
client.customer_pages.update("page-1", {"pageName": "North fleet"})
client.customer_pages.rotate_token("page-1")                   # old /p/ URL stops resolving
client.customer_pages.revoke("page-1")
client.customer_pages.get_branding()
client.customer_pages.update_branding({"accentColor": "#0f4c81"})
```

### Account — `client.account`

```python
client.account.get()             # any valid key -> Account
client.account.usage(days=30)    # any valid key -> per-key request counts (max 40 days)
```

## Pagination

`iterate*` methods return a `Paginator`. Iterate it for individual items, call `.pages()` for a page at a time, or `.all()` to collect everything. Pages are fetched lazily, so a large collection never has to fit in memory.

```python
pager = client.tags.iterate(limit=100)

for tag in pager:           # individual items, across all pages
    ...

for page in pager.pages():  # one page (list) at a time
    ...

every = pager.all()         # eager list of every item
```

## Verifying incoming webhooks

Every delivery carries an `X-TagLogger-Signature` header (`sha256=<hex>`, an HMAC-SHA256 of the **raw** request body keyed by the webhook's signing secret). Verify against the raw bytes before trusting the payload — re-serializing the JSON changes the bytes and fails the check. [Delivery](https://taglogger.com/docs/webhooks/delivery) covers retries and timeouts, and [the event catalogue](https://taglogger.com/docs/webhooks/events) lists the payload of every event type.

```python
from taglogger_sdk import (
    construct_webhook_event,
    verify_webhook_signature,
    SIGNATURE_HEADER,
    TagLoggerError,
)

# Flask example
@app.post("/hooks/taglogger")
def handle():
    raw = request.get_data()                      # raw bytes, not request.json
    signature = request.headers.get(SIGNATURE_HEADER)
    try:
        event = construct_webhook_event(raw, signature, WEBHOOK_SECRET)
    except TagLoggerError:
        return "", 400                            # invalid_signature or invalid_body
    # event is the parsed payload, e.g. {"event": "geofence.entry", ...}
    return "", 200
```

`verify_webhook_signature(raw_body, signature, secret) -> bool` is the lower-level check; it returns `False` (never raises) on a missing header or mismatch.

## Error handling

Any non-2xx response, or a transport failure, raises `TagLoggerError`. [The error reference](https://taglogger.com/docs/api-reference/errors) explains each HTTP status and the common error codes, and [troubleshooting](https://taglogger.com/docs/troubleshooting) covers the ones you can fix on your side.

```python
from taglogger_sdk import TagLoggerError

try:
    client.tags.get("does-not-exist")
except TagLoggerError as err:
    err.code     # stable machine-readable code, e.g. "not_found"
    err.status   # HTTP status, or 0 for a transport/network failure
    err.message
    err.request_id          # from X-Request-Id, when present
    err.is_rate_limit       # True on HTTP 429
    err.is_transport        # True when the request never reached the server
    err.retry_after_seconds # parsed from Retry-After, when present
    err.details             # machine-readable detail on some errors, else None
```

## Analytics — `taglogger_sdk.analytics`

A bundled, dependency-free toolkit that turns the location data the API returns into stops, trips, dwell intervals, clusters and map-ready shapes. Everything runs locally; nothing is sent anywhere.

```python
from taglogger_sdk.analytics import (
    fixes_from_location_points,
    condition_fixes,
    detect_stops,
    detect_trips,
    to_feature_collection,
)

# Pull a tag's history, then analyze it in-process
page = client.tags.history("tag-1", order="asc", limit=500)
fixes = fixes_from_location_points(page["data"])

clean = condition_fixes(fixes, max_accuracy_meters=100)
stops = detect_stops(clean, radius_meters=50, min_duration_ms=300_000)  # 5 min
trips = detect_trips(clean, stops)

geojson = to_feature_collection(trips=trips, stops=stops)  # ready for any map
```

Battery projection from a series of fractional-level readings (`0.0`–`1.0`) that
you collect over time. Note that `client.tags.battery("tag-1")` reports a coarse
integer `status` code (`0` unknown … `4` critical; see `BatteryStatus`) and its
numeric `level` is currently always `None`, so
the estimator is designed for series you assemble yourself — for example by
recording the last known good fractional level alongside its timestamp:

```python
from taglogger_sdk.analytics import BatterySample, estimate_battery_days_remaining

# Two fractional-level samples taken roughly a week apart.
est = estimate_battery_days_remaining([
    BatterySample(level=0.92, timestamp_ms=1_700_000_000_000),
    BatterySample(level=0.58, timestamp_ms=1_700_600_000_000),
])
print(est.days_remaining, est.drain_per_day, est.reason)
```

**Available helpers**

- **Types & adapters:** `Fix`, `Stop`, `Trip`, `Cluster`, `DwellInterval`, `BoundingBox`, `LatLng`, `BatterySample`, `BatteryEstimate`, `fix_from_location_point`, `fixes_from_location_points`
- **Conditioning:** `sort_by_time`, `dedupe_by_timestamp`, `condition_fixes`
- **Segmentation:** `detect_stops`, `detect_trips`, `compute_dwell` (a circle, or any geofence: `compute_dwell(fixes, geofence)`), `cluster_fixes` (DBSCAN)
- **Geometry:** `haversine_meters`, `bearing_degrees`, `centroid`, `bounding_box`, `path_length_meters`, `point_in_polygon`, `is_within_radius`, `is_inside_geofence`, `geofence_signed_distance_meters`, `douglas_peucker`, `encode_polyline`, `decode_polyline`
- **Shaping:** `to_point`, `to_line_string`, `to_polyline`, `to_feature_collection`, `trip_to_feature`, `stop_to_feature`, `fixes_to_feature`

`is_inside_geofence(point, geofence)` takes a geofence dict as the API returns it and follows the same rules TagLogger alerts use: polygon edges are straight lines on the map, and a corridor covers `widthMeters / 2` on each side of its route.

**Performance:** `douglas_peucker` runs in `O(n log n)` on typical tracks but can reach `O(n**2)` on a path that retains nearly every vertex (a dense zig-zag). For very large inputs, down-sample first or raise `epsilon_meters`. `encode_polyline` raises `ValueError` on non-finite (`nan`/`inf`) coordinates.

## Custom transport

Networking is pluggable. Inject any callable matching the transport signature to run hermetic tests or route requests through your own stack:

```python
from taglogger_sdk import TagLoggerClient
from taglogger_sdk.http import HttpResponse

def fake_transport(method, url, headers, body, timeout):
    return HttpResponse(status=200, headers={"content-type": "application/json"}, text='{"data": []}')

client = TagLoggerClient(api_key="tl_test_x", transport=fake_transport)
```

## Support

Start with [troubleshooting](https://taglogger.com/docs/troubleshooting) for the errors you can fix on your side, and use [the API console](https://taglogger.com/docs/api-console) to build a ready-to-run curl, JavaScript or Python request. For anything else, get in touch through [taglogger.com](https://taglogger.com/#get-in-touch).

## License

MIT
