Python wrapper for the Spot AI REST API, plus one thing the API doesn't give you: turning a damage claim into a packaged, footage-linked case.
Unofficial. Not affiliated with, endorsed by, or supported by Spot AI. Built against the public API documented at developers.spot.ai.
Using an AI coding assistant? Hand it
llm/spotai-agent-guide.md — a complete
technical manual written for Claude Code, Codex, and similar tools. Download
it, drop it in your project as CLAUDE.md or AGENTS.md, and your assistant
will know how to use this library correctly.
Contents
- What this actually does
- Setting up
- Your first script
- Site maps: the one thing you configure
- Making a damage claim
- Getting the video back
- Using it in a web app
- When things go wrong
- Full method reference
- Things the API does that will surprise you
- FAQ
- Design notes
A customer says their car was scratched in your wash. To prove what happened you need video of that specific car, from every camera it drove past, at exactly the right moments.
By hand that's slow. You work out when the car went through, then open each camera and scrub to the right spot — and because a car takes three or four minutes to travel the tunnel, every camera needs a different time.
This library does it for you. Give it a licence plate (or just a time) and it:
- Works out exactly when the car entered
- Works out when each camera along the tunnel saw it
- Asks Spot AI to cut a clip from each one
- Creates a case in Spot AI so there's a record
- Gives you one link showing every camera side by side
Twenty minutes of scrubbing becomes one function call.
By licence plate — if the site has a plate-reading (LPR) camera, it finds the car itself.
By time — if it doesn't, someone types roughly when the car went through. Everything after that is identical.
That second option matters: most sites don't have LPR cameras, and the tool is just as useful there.
pip install git+https://github.com/christopher-nance/spotai-python-wrapper.gitOr pin a version in requirements.txt:
git+https://github.com/christopher-nance/spotai-python-wrapper.git@v0.1.0
- Log in to the Spot AI dashboard as an organisation admin
- Settings → API
- Create New Key, name it, Generate Key
- Copy it immediately — Spot shows it exactly once
- Click the key, then "Add new" above the authorisations table, and give it a Role (Owner for full access)
Step 5 is the one everybody misses. A key without a Role connects fine but can't see anything. The next section checks for this.
Create a .env file:
spotai_api_key=zpka_your_key_here
Add .env to your .gitignore and never commit it. Anyone with that key can
watch your cameras.
Start here — this proves your key works before anything else matters.
import os
from spotai import SpotAI
spot = SpotAI(api_key=os.environ["SPOTAI_API_KEY"])
print("Key accepted:", spot.verify_key())
print("Cameras in the org:", spot.camera_count())
print("Cameras I can see:", len(spot.cameras()))
for location in spot.locations():
print(" ", location["id"], location["name"])What you want: the two camera numbers roughly match, and your locations are listed.
If camera_count() shows a number but cameras() shows 0 — that's the
missing Role from step 5. Go add it. Nothing else will work until you do.
for camera in spot.cameras(location_ids=[1001]):
print(camera["id"], camera["name"], camera["status"])Write down the IDs in the order a car drives past them. That's the next step.
This is the only fiddly part, so here's why it exists.
Spot AI knows your cameras exist. It does not know what order a car passes them — there's no "position" field anywhere in the API. So it can't know the exit camera sees a car three minutes after the entry camera does.
You tell it once, per site. That's a SiteMap.
from spotai import SiteMap, Camera
wheaton = SiteMap(
location_id=1001,
location_name="Wheaton",
timezone="America/Chicago",
transit_seconds=240, # how long a wash takes, in seconds
lpr_camera_id=2001, # the plate-reading camera, or None
cameras=[
Camera(id=2001, name="LPR", role="entry"),
Camera(id=2002, name="Tunnel Entrance", role="tunnel"),
Camera(id=2003, name="SmartStop 1", role="tunnel"),
Camera(id=2004, name="SmartStop 2", role="tunnel"),
Camera(id=2005, name="Exit Inspection", role="exit"),
Camera(id=2006, name="Pole Exit", role="exit"),
],
)List the cameras in the order a car drives past them. That's the trick.
Doing this with an AI assistant? Point it at
llm/build-site-map-guide.md. It walks through pulling your locations and cameras, proposing which are tunnel cameras and which is the LPR camera, and confirming the order with you before writingsite_maps.json. Much faster than doing it by hand for a site with 30+ cameras.
| Role | Meaning |
|---|---|
entry |
where the car starts — usually the LPR or entrance camera |
tunnel |
anything in the middle |
exit |
the end, including exit inspection cameras |
Give it transit_seconds and it spreads the cameras across that time:
transit_seconds = 240 (4 minutes)
LPR 0 seconds after entry
Tunnel Entrance 60
SmartStop 1 120
SmartStop 2 180
Exit Inspection 240
Pole Exit 240
So each camera's clip covers when that camera saw the car:
T0 = 10:09:00, clip 120s, padding -30/+60
LPR 0s 10:08:30 -> 10:12:00
Tunnel Entrance 60s 10:09:30 -> 10:13:00
SmartStop 1 120s 10:10:30 -> 10:14:00
Exit Inspection 240s 10:12:30 -> 10:16:00 <- correctly excludes T0
These are estimates. Once you've timed a real car, set the exact number:
Camera(id=2003, name="SmartStop 1", role="tunnel", offset_seconds=95),Anything you set by hand is left alone; anything you don't is estimated.
spot = SpotAI(api_key=..., site_maps=[wheaton, niles, plainfield])Build one per location, hand them all over at once, then refer to sites by name.
Hard-coding seventeen sites gets ugly. Use JSON:
import json
from spotai import SiteMap
with open("site_maps.json") as f:
site_maps = [SiteMap.from_dict(d) for d in json.load(f)]To create that file, build one in Python and print it:
print(json.dumps(wheaton.to_dict(), indent=2))claim = spot.collect_damage_claim(
location="Wheaton",
customer="J. Smith",
plate="ABC1234",
claim_ref="CLAIM-118",
)
print(claim.id) # WHEATON:CLAIM-118:ABC1234:2026-08-30
print(claim.device_id) # 539 <- save this
print(claim.event_id) # 0ed1... <- and this
print(claim.status) # pendingSave device_id and event_id in your database. They're how you find the
claim later.
claim = spot.collect_damage_claim(
location="Niles",
customer="M. Garcia",
at="2026-08-30 10:09", # when the car entered, site local time
)The time is wall-clock time at that site. If staff say "about ten past
ten," type 10:09. Don't convert anything.
| Argument | Required? | What it's for |
|---|---|---|
location |
yes | which site — name or ID |
customer |
yes | goes in the case name |
plate |
one of these | the licence plate to look up |
at |
one of these | when the car entered, instead of a plate |
date |
no | which day (with plate); defaults to today |
claim_ref |
no | your own claim number |
occurrence |
no | "first" or "last" — see below |
fuzzy |
no | also try similar-looking plates |
reuse_existing |
no | defaults to True |
Pass either plate or at, never both — they'd disagree about when
the car went through.
Cutting video takes minutes, so this doesn't wait. It starts everything and hands back a receipt. You collect the video separately.
If someone double-clicks your form you get the same claim back, not two. It's recognised by plate, date, site, and claim reference.
claim.reused # True if it already existedPlate readers confuse 0/O, 8/B, 1/I, 5/S, 2/Z. If a plate
isn't found:
claim = spot.collect_damage_claim(..., plate="ABC1234", fuzzy=True)Off by default, because it can match the wrong car.
Plate readers are not reliable enough for exact matching. Measured on 521 real
reads from one site, 46% were shorter than a full plate — the reader loses
characters off the front. So a claim for AB12345 would find nothing whenever
that car happened to be read as 12345.
The library handles this for you: it scores every plate the reader saw that day
and picks the best match, rather than asking for an exact one. It copes with
lower case, spaces (C12 3456), dashes, a state prefix, a typo, look-alike
characters (0/O, 8/B, 5/S), and missing characters.
You can also just ask who it thinks the car was:
for c in spot.match_plate("Wheaton", "AB12345", date="2026-08-30"):
print(c.plate, c.score, c.band)AB12345 1.00 near-certain
12345 0.87 likely
| Band | What to do |
|---|---|
near-certain |
trust it |
likely |
trust it, worth a glance |
possible |
have a person confirm which car |
| nothing returned | the reader never saw that car — use the time instead |
An empty result is not an error. Plate readers miss cars. That is what the timestamp fallback is for, and it is why passing both a plate and a time is the best way to call this.
Junk entries — N/A, TEST, 1111, a note pasted into the plate box — are
ignored rather than matched against, so they cannot produce a confident wrong
answer.
claim = spot.collect_damage_claim(
location="Wheaton",
customer="J. Smith",
plate="ABC1234", # preferred
at="2026-08-30 10:09", # fallback if the plate can't be matched
)The plate wins when the reader confidently saw it. Otherwise the time is used.
claim.anchor tells you which happened.
When the anchor is a typed time, the library makes a wide, scrubbable link instead of cutting clips.
That is deliberate. We compared real typed times against what the plate reader actually recorded: people were off by about 7 minutes on average, and up to 15. A 90-second clip built on a guess often misses the car — and a clip of the wrong car is worse than no clip, because it still looks like evidence.
So a reviewer scrubs the wide view to find the car. Once you know the real time, re-run with it to get precise clips.
Override with clips="always" or clips="never" if you want different
behaviour.
The plate camera watches each car for about a minute as it approaches and queues, so there are two timestamps: when it first saw the car and when it last did.
By default it uses the first. If your clips consistently start too early — you see the car queueing rather than entering — switch:
claim = spot.collect_damage_claim(..., occurrence="last")Worth testing once per site, then leaving alone.
result = spot.get_claim(claim.device_id, claim.event_id)
print(result.status)
print(result.share_link)
for clip in result.clips:
print(clip.name, clip.url)| Status | What it means | What to do |
|---|---|---|
pending |
still cutting video | wait, check again |
ready |
every clip is done | show them |
partial |
some worked, some didn't | show what you have |
link-only |
no clips cut — the time was an estimate | show the share link |
failed |
nothing worked | check result.problems |
partial is normal. Spot's export occasionally gets stuck on one camera —
we've seen one sit unfinished for 25 minutes while the others finished in
under three. Fifteen good clips beat a failed claim, so one bad camera never
sinks the case. result.problems says which and why.
The single most important thing here.
Never save a clip URL to your database. It'll be dead within the hour and your evidence page will show broken video.
Call get_claim when someone opens the page and use the fresh URLs straight
away:
# WRONG - broken in an hour
db.save(claim_id, [c.url for c in result.clips])
# RIGHT - always fresh
def view_claim(claim_id):
record = db.get(claim_id)
result = spot.get_claim(record.device_id, record.event_id)
return render(clips=result.clips)Every clip carries its real deadline in clip.url_expires.
The share link is different — it lasts 7 days and is safe to email.
Spot deletes exported video after a week. If you need evidence for longer — and insurance claims usually run longer — download the files and store them yourself within the first week. This library gives you links, not files. Saving them is your side of the job.
When a claim is submitted — call collect_damage_claim, save device_id
and event_id next to your claim record.
When someone views it — call get_claim and render what's ready. Never
cache the clip URLs.
@app.route("/claims/<claim_id>")
def view_claim(claim_id):
record = db.get_claim(claim_id)
result = spot.get_claim(record.spot_device_id, record.spot_event_id)
return render_template(
"claim.html",
status=result.status,
clips=result.clips,
share_link=result.share_link,
problems=result.problems,
)Create the client once, at startup, not per request — it reuses its HTTP connection and remembers which integration to use.
If form submission must be instant, move collect_damage_claim into a
background job. It's usually a few seconds, but it does make several API
calls.
The key has no Role. Dashboard → Settings → API → your key → "Add new" above the authorisations table → Role. By far the most common problem.
Key is wrong, expired, or deleted. Keys expire a year after creation.
Key works but its Role doesn't cover that camera or site. Widen the Role.
You wrote something matching two sites — like "Example" when every site starts
that way. Use the full name or the location ID. It refuses to guess, because
pulling video from the wrong building is worse than an error.
That site has no plate camera. Use at="..." instead, or set lpr_camera_id
on its SiteMap.
No matching plate that day. Check the date, try fuzzy=True, or fall back to
at="...".
You passed both, or neither.
Your camera order or timings are off:
- Cameras listed in the wrong order — fix the order in the
SiteMap transit_secondsdoesn't match reality — time a real car- Clips start too early — try
occurrence="last"
Time one car through with a stopwatch and set offset_seconds explicitly. A
five-minute job that fixes it permanently.
It expired — they last an hour. Call get_claim again.
| Method | What it does |
|---|---|
collect_damage_claim(...) |
Build a claim case. Returns a Claim. |
get_claim(device_id, event_id=None) |
Current status, clips, problems. |
site.all_camera_ids() |
Every camera, for a claim that wants all of them on devices. |
match_plate(location, plate, date=None) |
Rank what the plate reader saw against what was typed. |
site_map(location) |
Look up a configured SiteMap. |
| Method | What it does |
|---|---|
verify_key() |
True if the key is accepted. |
camera_count() |
Org-wide camera count (ignores key scope). |
cameras(location_ids=None) |
Cameras you can see. |
camera(camera_id) |
One camera's details. |
locations() |
All locations you can see. |
zones(camera_id) |
Zones defined on a camera. |
| Method | What it does |
|---|---|
lpr_report(camera_id, start, end, plates=None) |
Plate reads for a camera and time range. |
interest_lists() |
Plate watch-lists configured in Spot. |
| Method | What it does |
|---|---|
create_footage_job(camera_id, start, end) |
Start cutting one clip. |
get_footage_job(camera_id, footage_id) |
Check on one clip. |
create_shared_search(camera_ids, start, end) |
Public multi-camera link. |
create_vod_embed(camera_id, start, end) |
Embeddable single-camera player. |
| Method | What it does |
|---|---|
integrations() |
List integrations. |
devices(integration_id, tags=None) |
List cases/devices, filterable by tag. |
events(integration_id, device_ids=..., camera_ids=...) |
List events. Needs one filter. |
Times are ISO 8601 UTC strings, e.g. "2026-08-30T15:09:00.000Z".
Each confirmed against a live production organisation, and each handled for you.
| Behaviour | What it means |
|---|---|
Base URL is dev-api.spot.ai |
The only server in Spot's spec; api.spot.ai 404s on /v1/*. Not a sandbox. |
| A key with no Role reads empty | camera_count() returns a number while cameras() returns []. |
| Clip URLs live 1 hour | Signed URLs, re-signed each request. Fetch at view time. |
| Clip URLs reject auth headers | They're pre-signed; adding Authorization breaks them. |
| Event list needs a filter | Returns [] unless filtered by device or camera. |
| Event ingestion is async | Returns 202; an event may not be queryable for a moment. |
| Attributes aren't validated | Events whose attributes don't match the schema are still accepted. |
| Device name ≤ 40 chars | Enforced with a 400. Names are truncated, keeping the date. |
| Cameras per device ≤ 4 | Enforced with a 400. A claim wanting more gets one device per four. |
| Shared link ≤ 16 cameras, ≤ 7 days | Hard ceilings. A 23-camera site keeps both inspection arches in the link and thins mid-tunnel cameras; every clip is still exported. |
| Exports sometimes wedge | One observed at 0% for 25+ minutes while a sibling finished in 2m36s. |
Do I need to understand the Spot AI API? No. That's what this is for.
Can I use it without LPR cameras? Yes — use at="2026-08-30 10:09". Most
sites work this way.
How many cameras can one claim have? As many as you like for the clips. The share link shows up to 16. The case record inside Spot shows 4 — Spot's limit, not ours.
Why only 4 on the case? Spot allows at most four cameras per integration device. By default the library picks the most useful four.
If you want more, ask for more — set key_camera_ids to as many cameras as
you like and the library creates one device per four:
wheaton.key_camera_ids = wheaton.all_camera_ids() # all of themA 23-camera claim becomes 6 devices named J.Smith | 2026-08-30 (1/6)
through (6/6), each carrying its own event so all 23 surface natively in
Spot. Re-submitting still returns the same claim rather than another six.
Once you have pulled the clips into your own records you can delete the extra devices in Spot — nothing else depends on them.
Does this store video? No, it gives you links. Spot deletes video after 7 days, so download anything you need to keep.
Is it safe to call twice? Yes — the same details return the same claim.
How long does a claim take? The call returns in seconds; video is usually ready in two to five minutes.
What Python version? 3.9 or newer.
Is this made by Spot AI? No. Independent wrapper around their public API.
One device per claim. Spot's model calls a device a physical
device/object, so a claim is arguably an Event, not a Device. Devices are
used here because they're the named, listable things under an integration. The
costs: devices grow without bound, and the idempotency lookup scans devices at
a site. If claim volume gets large, moving to one device per tunnel with an
event per claim is the change to make — it's contained in damage_claims.py.
Layout. transport.py is HTTP only. client.py is the public surface and
stays thin. damage_claims.py holds the workflow. Adding an endpoint is a
three-line method; adding a workflow is a new module.
Step order in collect_damage_claim is deliberate. The Spot device is
created before exports are submitted, so a mid-flight failure leaves a
recoverable record rather than orphaned export jobs nothing points at.
| File | Audience |
|---|---|
| This README | People — the complete guide |
llm/spotai-agent-guide.md |
AI coding assistants — using the library |
llm/build-site-map-guide.md |
AI coding assistants — building your camera directory |
docs/GUIDE.md |
Same guide, standalone copy |
CONTRIBUTING.md |
Changing the library |
Both guides are kept in step by tests/test_docs_current.py, which fails the
build if a public method goes undocumented.
pip install -e ".[dev]"
pytest -qNo API access or credentials needed. They cover the parts where bugs are silent: time and timezone maths, offset seeding, name truncation, identity construction, and status derivation.
MIT