Python SDK
Official Python SDK for the AirPinpoint asset tracking API. Sync and async clients with Pydantic models.
The airpinpoint Python package provides sync and async clients for the AirPinpoint API, with full type safety via Pydantic v2 models.
Installation
pip install airpinpointRequires Python 3.9+. Dependencies: httpx and pydantic v2.
Quick Start
Set your API key as an environment variable or pass it directly to the client.
from airpinpoint import AirPinpoint
client = AirPinpoint(api_key="your-api-key")
# List all trackable devices
page = client.trackables.list()
for trackable in page.data:
print(f"{trackable.name}: enabled={trackable.enabled}")
# Get current location
location = client.trackables.current_location(page.data[0].id)
print(f"Lat: {location.latitude}, Lng: {location.longitude}")The client reads AIRPINPOINT_API_KEY from your environment automatically if no api_key is provided.
Async Client
For async applications, use AsyncAirPinpoint. All resource methods return awaitables and support async for pagination.
from airpinpoint import AsyncAirPinpoint
async def main():
async with AsyncAirPinpoint(api_key="your-api-key") as client:
page = await client.trackables.list()
for trackable in page.data:
print(trackable.name)
# Async auto-pagination
async for trackable in client.trackables.list():
print(trackable.name)Configuration
Both AirPinpoint and AsyncAirPinpoint accept the same constructor options.
| Option | Type | Default | Description |
|---|---|---|---|
api_key | str | AIRPINPOINT_API_KEY env var | Your API key |
base_url | str | https://api.airpinpoint.com/v1 | API base URL |
timeout | float | 30.0 | Request timeout in seconds |
max_retries | int | 2 | Automatic retries on failure |
default_headers | dict | None | Headers included with every request |
Tip
Context managers (with / async with) ensure the HTTP connection is properly closed. Always use them in long-running applications to avoid connection leaks.
# Sync context manager
with AirPinpoint(api_key="your-api-key", timeout=60.0) as client:
page = client.trackables.list()
# Custom base URL for self-hosted deployments
client = AirPinpoint(
api_key="your-api-key",
base_url="https://your-instance.example.com/v1",
default_headers={"X-Custom-Header": "value"},
)Resources: Trackables
Trackables represent AirTags and Find My-compatible devices linked to your account.
List trackables
page = client.trackables.list(skip=0, limit=20)
# Returns SyncPage[TrackableDetail]
for trackable in page.data:
print(f"{trackable.name} ({trackable.model}): enabled={trackable.enabled}")Retrieve a trackable
trackable = client.trackables.retrieve("trk_abc123")
print(f"{trackable.name}, paired at {trackable.paired_at}")Get current location
location = client.trackables.current_location("trk_abc123")
print(f"Delivery Van #3 is at {location.latitude}, {location.longitude}")
print(f"Accuracy: {location.horizontal_accuracy}m")
print(f"Last seen: {location.timestamp}")Get location history
from datetime import datetime, timedelta, timezone
end = datetime.now(timezone.utc)
start = end - timedelta(days=7)
page = client.trackables.location_history(
"trk_abc123",
start_time=start,
end_time=end,
limit=100,
)
for loc in page.data:
print(f"{loc.timestamp}: ({loc.latitude}, {loc.longitude})")Get battery info
battery = client.trackables.battery("trk_abc123")
print(f"Level: {battery.battery_level}") # 0=full, 1=medium, 2=low, 3=critical
print(f"Days remaining: {battery.estimated_days_remaining}")Reset battery
After replacing a battery, reset the counter to keep estimates accurate.
result = client.trackables.reset_battery("trk_abc123", battery_months=12)
print(result.message) # "Battery reset successfully"Tip
Reset the battery counter every time you swap an AirTag battery. This keeps the estimated days remaining accurate across your fleet.
Resources: Geofences
Geofences are virtual boundaries that trigger notifications when devices enter or exit.
List geofences
page = client.geofences.list(skip=0, limit=20)
for fence in page.data:
print(f"{fence.name}: radius={fence.radius}m")
# Filter by trackable
page = client.geofences.list(trackable_id="trk_abc123")Create a geofence
geofence = client.geofences.create(
name="Warehouse Zone A",
latitude=37.7749,
longitude=-122.4194,
radius=200,
trackable_id=["trk_abc123", "trk_def456"],
)
print(f"Created geofence: {geofence.id}")Update a geofence
updated = client.geofences.update(
"geofence_789012",
name="Warehouse Zone A (Expanded)",
radius=350,
)
print(f"Updated radius to {updated.radius}m")Delete a geofence
client.geofences.delete("geofence_789012")Test a webhook
Send a test event to verify your webhook endpoint is receiving payloads correctly.
result = client.geofences.test_webhook("geofence_789012", event_type="entry")
print(f"Delivery ID: {result.delivery_id}")Webhook delivery history
# List recent deliveries
deliveries = client.geofences.webhooks.list(
"geofence_789012",
limit=10,
successful=True,
)
for d in deliveries.data:
print(f"{d.id}: status={d.status_code}, at={d.created_at}")
# Get a specific delivery
delivery = client.geofences.webhooks.retrieve("geofence_789012", "whd_abc123")
print(f"Response body: {delivery.response_body}")Full lifecycle example
from airpinpoint import AirPinpoint
client = AirPinpoint(api_key="your-api-key")
# 1. List devices to get IDs
page = client.trackables.list()
forklift_ids = [t.id for t in page.data if "Forklift" in t.name]
# 2. Create a geofence around the loading dock
geofence = client.geofences.create(
name="Loading Dock B",
latitude=37.7749,
longitude=-122.4194,
radius=150,
trackable_id=forklift_ids,
)
# 3. Test the webhook to make sure it works
result = client.geofences.test_webhook(geofence.id, event_type="entry")
print(f"Test delivery: {result.delivery_id}")
# 4. Later, expand the zone
client.geofences.update(geofence.id, radius=250)
# 5. Clean up when no longer needed
client.geofences.delete(geofence.id)Resources: Share Links, Account, Usage
Share links
Generate a temporary public link to share a device's live location.
link = client.share_links.create(trackable_id="trk_abc123", hours=24)
print(f"Share URL: {link.url}") # valid for 24 hoursAccount
account = client.account.retrieve()
print(f"Org: {account.organization_name}")
print(f"Plan: {account.plan}")
print(f"Devices: {account.device_count}/{account.device_limit}")Usage
from datetime import date
usage = client.usage.retrieve(
start_date=date(2025, 1, 1),
end_date=date(2025, 1, 31),
)
print(f"API calls: {usage.api_calls}")
print(f"Location fetches: {usage.location_fetches}")
# Get totals for a billing period
totals = client.usage.total(
start_date=date(2025, 1, 1),
end_date=date(2025, 1, 31),
)
print(f"Total API calls: {totals.total_api_calls}")Auto-Pagination
All .list() methods return paginated responses. You can work with them in two ways.
Manual page access
page = client.trackables.list(limit=10)
print(page.data) # list of TrackableDetail objects
print(page.has_more) # True if more pages exist
print(page.total) # total count across all pagesAutomatic iteration
The SDK handles pagination transparently when you iterate directly over the result.
# Sync: iterates through all pages automatically
for trackable in client.trackables.list():
print(trackable.name)
# Async: same behavior with async for
async for trackable in client.trackables.list():
print(trackable.name)This works for any list method: trackables.list(), trackables.location_history(), geofences.list(), geofences.webhooks.list(), and so on.
Error Handling
The SDK raises specific exception classes based on the HTTP status code.
| Error Class | Status | When |
|---|---|---|
AuthenticationError | 401 | Invalid or missing API key |
NotFoundError | 404 | Resource doesn't exist |
ValidationError | 400 | Invalid request parameters |
RateLimitError | 429 | Too many requests |
InternalError | 500+ | Server error |
APIConnectionError | - | Network or timeout |
WebhookSignatureError | - | Invalid webhook signature |
All API errors extend APIError, which includes status_code, message, and response attributes.
from airpinpoint import AirPinpoint, APIError, NotFoundError, RateLimitError
client = AirPinpoint(api_key="your-api-key")
try:
trackable = client.trackables.retrieve("trk_nonexistent")
except NotFoundError:
print("Device not found. Check the trackable ID.")
except RateLimitError as e:
print(f"Rate limited. Retry after {e.response.headers.get('Retry-After')}s")
except APIError as e:
print(f"API error {e.status_code}: {e.message}")Warning
The SDK automatically retries on 429 and 5xx errors (up to max_retries times) with exponential backoff. You only need to handle these if all retries are exhausted.
Webhook Verification
Verify that incoming webhook requests are authentic using the Webhook class. This checks the HMAC-SHA256 signature included in the X-AirPinpoint-Signature header.
from airpinpoint import Webhook, WebhookSignatureError
try:
event = Webhook.construct_event(
payload=request.body,
signature=request.headers["X-AirPinpoint-Signature"],
secret="whsec_your_secret",
)
if event.event == "geofence.entry":
print(f"{event.beacon.name} entered {event.geofence.name}")
elif event.event == "geofence.exit":
print(f"{event.beacon.name} left {event.geofence.name}")
except WebhookSignatureError:
print("Invalid signature")Flask example
from flask import Flask, request, Response
from airpinpoint import Webhook, WebhookSignatureError
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret"
@app.route("/webhooks/geofence", methods=["POST"])
def handle_geofence_webhook():
try:
event = Webhook.construct_event(
payload=request.get_data(as_text=True),
signature=request.headers["X-AirPinpoint-Signature"],
secret=WEBHOOK_SECRET,
)
except WebhookSignatureError:
return Response("Invalid signature", status=400)
if event.event == "geofence.entry":
notify_dispatch(event.beacon.name, event.geofence.name)
return Response("OK", status=200)FastAPI example
from fastapi import FastAPI, Request, HTTPException
from airpinpoint import Webhook, WebhookSignatureError
app = FastAPI()
WEBHOOK_SECRET = "whsec_your_secret"
@app.post("/webhooks/geofence")
async def handle_geofence_webhook(request: Request):
body = await request.body()
signature = request.headers.get("X-AirPinpoint-Signature", "")
try:
event = Webhook.construct_event(
payload=body.decode(),
signature=signature,
secret=WEBHOOK_SECRET,
)
except WebhookSignatureError:
raise HTTPException(status_code=400, detail="Invalid signature")
if event.event == "geofence.exit":
print(f"{event.beacon.name} left {event.geofence.name}")
return {"status": "ok"}Utility Functions
The SDK includes utility functions for common geospatial operations, data analysis, format conversions, filtering, and battery monitoring. Import them from airpinpoint.utils.
Geo Utilities
| Function | Signature | Description |
|---|---|---|
distance | distance(loc_a, loc_b, unit="meters") | Haversine distance between two locations |
bearing | bearing(loc_a, loc_b) | Compass bearing from A to B in degrees |
is_inside_circle | is_inside_circle(location, center_lat, center_lng, radius_m) | Whether a point is inside a circular area |
is_inside_polygon | is_inside_polygon(location, polygon_coords) | Whether a point is inside a polygon |
is_inside_geofence | is_inside_geofence(location, geofence) | Whether a location is inside a GeofenceBase |
distance_to_geofence | distance_to_geofence(location, geofence) | Distance from a point to the geofence boundary |
buffer_geofence | buffer_geofence(geofence, buffer_m) | Expand or shrink a geofence radius |
from airpinpoint.utils import distance, bearing, is_inside_geofence
# How far is Forklift #42 from the warehouse?
dist = distance(forklift_location, warehouse_location, unit="meters")
print(f"Distance: {dist:.1f}m")
# Which direction is it heading?
heading = bearing(previous_location, forklift_location)
print(f"Bearing: {heading:.0f} degrees")
# Is it inside the geofence?
inside = is_inside_geofence(forklift_location, warehouse_geofence)
print(f"Inside warehouse zone: {inside}")Analysis Utilities
| Function | Signature | Description |
|---|---|---|
speed | speed(loc_a, loc_b, unit="km/h") | Speed between two consecutive locations |
detect_stops | detect_stops(locations, min_duration_s=300, radius_m=50) | Find periods where a device was stationary |
detect_trips | detect_trips(locations, min_distance_m=100, min_duration_s=60) | Extract trip segments from location history |
dwell_time | dwell_time(locations, center_lat, center_lng, radius_m) | Total time spent within an area |
simplify_path | simplify_path(locations, tolerance=0.0001) | Reduce points using Ramer-Douglas-Peucker |
cluster_locations | cluster_locations(locations, radius_m=100) | Group nearby locations into clusters |
from airpinpoint.utils import detect_stops, dwell_time
# Find where Delivery Van #3 stopped for more than 5 minutes
history = client.trackables.location_history(
"trk_abc123",
start_time=start,
end_time=end,
)
stops = detect_stops(list(history), min_duration_s=300, radius_m=50)
for stop in stops:
print(f"Stopped at ({stop.latitude}, {stop.longitude}) "
f"for {stop.duration_s / 60:.0f} min")
# How long did it spend at the job site today?
minutes = dwell_time(
list(history),
center_lat=37.7749,
center_lng=-122.4194,
radius_m=200,
)
print(f"Time on site: {minutes / 60:.1f} hours")Format Utilities
| Function | Signature | Description |
|---|---|---|
to_geojson | to_geojson(locations) | Convert locations to a GeoJSON FeatureCollection |
to_geojson_point | to_geojson_point(location) | Convert a single location to a GeoJSON Feature |
to_geojson_line_string | to_geojson_line_string(locations) | Convert locations to a GeoJSON LineString |
encode_polyline | encode_polyline(locations) | Encode locations as a Google polyline string |
decode_polyline | decode_polyline(encoded) | Decode a polyline string to coordinate pairs |
from airpinpoint.utils import to_geojson, encode_polyline
import json
history = list(client.trackables.location_history(
"trk_abc123",
start_time=start,
end_time=end,
))
# Export as GeoJSON for mapping tools
geojson = to_geojson(history)
with open("route.geojson", "w") as f:
json.dump(geojson, f)
# Encode as polyline for Google Maps URLs
polyline = encode_polyline(history)
print(f"Polyline: {polyline}")Filter Utilities
| Function | Signature | Description |
|---|---|---|
filter_by_accuracy | filter_by_accuracy(locations, max_accuracy_m=50) | Keep only locations within accuracy threshold |
filter_by_speed | filter_by_speed(locations, max_speed_kmh=200) | Remove implausible GPS jumps |
filter_stale | filter_stale(locations, max_age_s=3600) | Remove locations older than a threshold |
deduplicate | deduplicate(locations, radius_m=5, time_window_s=60) | Remove duplicate points |
from airpinpoint.utils import (
filter_by_accuracy,
filter_by_speed,
deduplicate,
)
# Clean up raw location history before analysis
raw = list(client.trackables.location_history(
"trk_abc123",
start_time=start,
end_time=end,
limit=500,
))
# Pipeline: accuracy filter -> speed filter -> dedup
clean = filter_by_accuracy(raw, max_accuracy_m=30)
clean = filter_by_speed(clean, max_speed_kmh=150)
clean = deduplicate(clean, radius_m=10, time_window_s=120)
print(f"Kept {len(clean)}/{len(raw)} locations after cleanup")Battery Utilities
| Function | Signature | Description |
|---|---|---|
estimate_battery_days_remaining | estimate_battery_days_remaining(battery_info) | Estimated days of battery life left |
battery_health_status | battery_health_status(battery_info) | Returns "good", "replace_soon", or "critical" |
from airpinpoint.utils import estimate_battery_days_remaining, battery_health_status
# Monitor battery health across your fleet
page = client.trackables.list()
for trackable in page.data:
battery = client.trackables.battery(trackable.id)
days = estimate_battery_days_remaining(battery)
status = battery_health_status(battery)
if status != "good":
print(f"[{status.upper()}] {trackable.name}: ~{days} days remaining")Pydantic Models Reference
All response objects are Pydantic v2 models with snake_case attributes. When serialized to JSON, they use camelCase aliases to match the API.
| Model | Description |
|---|---|
TrackableDetail | Full device info including name, model, enabled status, paired date |
LocationBase | Latitude, longitude, timestamp, horizontal accuracy, battery level |
BatteryInfo | Battery level, last reset date, estimated days remaining |
GeofenceBase | Geofence name, center coordinates, radius, trackable IDs |
GeofenceDetail | Full geofence with webhook config and notification settings |
WebhookEvent | Parsed webhook payload with event type, geofence, beacon, location |
WebhookDelivery | Webhook delivery attempt with status code, response body, timestamp |
ShareLink | Share link URL and expiration time |
AccountInfo | Organization name, plan, device count and limit |
UsageInfo | API calls, location fetches, and other usage metrics |
UsageTotal | Aggregated usage totals for a date range |
Message | Simple message response (e.g., from battery reset) |
SyncPage[T] | Paginated response with data, has_more, total fields |
AsyncPage[T] | Async paginated response, supports async for iteration |
# Type hints work with all models
from airpinpoint.types import TrackableDetail, LocationBase
def process_device(device: TrackableDetail) -> None:
loc: LocationBase = client.trackables.current_location(device.id)
print(f"{device.name}: {loc.latitude}, {loc.longitude}")