...

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 airpinpoint

Requires 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.

OptionTypeDefaultDescription
api_keystrAIRPINPOINT_API_KEY env varYour API key
base_urlstrhttps://api.airpinpoint.com/v1API base URL
timeoutfloat30.0Request timeout in seconds
max_retriesint2Automatic retries on failure
default_headersdictNoneHeaders 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)

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 hours

Account

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 pages

Automatic 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 ClassStatusWhen
AuthenticationError401Invalid or missing API key
NotFoundError404Resource doesn't exist
ValidationError400Invalid request parameters
RateLimitError429Too many requests
InternalError500+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

FunctionSignatureDescription
distancedistance(loc_a, loc_b, unit="meters")Haversine distance between two locations
bearingbearing(loc_a, loc_b)Compass bearing from A to B in degrees
is_inside_circleis_inside_circle(location, center_lat, center_lng, radius_m)Whether a point is inside a circular area
is_inside_polygonis_inside_polygon(location, polygon_coords)Whether a point is inside a polygon
is_inside_geofenceis_inside_geofence(location, geofence)Whether a location is inside a GeofenceBase
distance_to_geofencedistance_to_geofence(location, geofence)Distance from a point to the geofence boundary
buffer_geofencebuffer_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

FunctionSignatureDescription
speedspeed(loc_a, loc_b, unit="km/h")Speed between two consecutive locations
detect_stopsdetect_stops(locations, min_duration_s=300, radius_m=50)Find periods where a device was stationary
detect_tripsdetect_trips(locations, min_distance_m=100, min_duration_s=60)Extract trip segments from location history
dwell_timedwell_time(locations, center_lat, center_lng, radius_m)Total time spent within an area
simplify_pathsimplify_path(locations, tolerance=0.0001)Reduce points using Ramer-Douglas-Peucker
cluster_locationscluster_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

FunctionSignatureDescription
to_geojsonto_geojson(locations)Convert locations to a GeoJSON FeatureCollection
to_geojson_pointto_geojson_point(location)Convert a single location to a GeoJSON Feature
to_geojson_line_stringto_geojson_line_string(locations)Convert locations to a GeoJSON LineString
encode_polylineencode_polyline(locations)Encode locations as a Google polyline string
decode_polylinedecode_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

FunctionSignatureDescription
filter_by_accuracyfilter_by_accuracy(locations, max_accuracy_m=50)Keep only locations within accuracy threshold
filter_by_speedfilter_by_speed(locations, max_speed_kmh=200)Remove implausible GPS jumps
filter_stalefilter_stale(locations, max_age_s=3600)Remove locations older than a threshold
deduplicatededuplicate(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

FunctionSignatureDescription
estimate_battery_days_remainingestimate_battery_days_remaining(battery_info)Estimated days of battery life left
battery_health_statusbattery_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.

ModelDescription
TrackableDetailFull device info including name, model, enabled status, paired date
LocationBaseLatitude, longitude, timestamp, horizontal accuracy, battery level
BatteryInfoBattery level, last reset date, estimated days remaining
GeofenceBaseGeofence name, center coordinates, radius, trackable IDs
GeofenceDetailFull geofence with webhook config and notification settings
WebhookEventParsed webhook payload with event type, geofence, beacon, location
WebhookDeliveryWebhook delivery attempt with status code, response body, timestamp
ShareLinkShare link URL and expiration time
AccountInfoOrganization name, plan, device count and limit
UsageInfoAPI calls, location fetches, and other usage metrics
UsageTotalAggregated usage totals for a date range
MessageSimple 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}")