pycar Upload API v1
API version 1 · 30 October 2026
pycar can send each vehicle file — photos and vehicle data — to a server you operate (DMS, website, marketplace, internal storage). This page describes the endpoints your server must provide. pycar never sends the photos anywhere else.
How a vehicle is sent
- The app authenticates (OAuth 2.0 token, or HTTP Basic).
PUT /v1/vehicles/{vehicle_id}— the vehicle file (JSON).PUT /v1/vehicles/{vehicle_id}/photos/{photo_id}— once per photo (multipart).POST /v1/vehicles/{vehicle_id}/complete— end of the transfer, with the list of photos.
Every call is idempotent: identifiers are stable, so an interrupted transfer simply resumes, and a photo already received with the same SHA-256 is not sent again.
Settings in the app
| Server | Base URL of your API, e.g. https://api.example.com/pycar. HTTPS is mandatory. The app appends /v1/… and /oauth/token. |
| Method | OAuth 2.0 with login / password, OAuth 2.0 with client ID / secret, or HTTP Basic. |
| Login, password | Stored in the phone’s secure keychain. |
| Client ID / secret | Optional with login / password; required with client credentials. |
| Advanced | Token URL (default {server}/oauth/token) and scope (default pycar.upload). |
| Content | Choose what is sent: exterior originals, studio shots, details, hotspots, 360°, VIN photo. Optional automatic send when the vehicle file is validated. |
Authentication
OAuth 2.0, login / password (grant password, RFC 6749 §4.3). If a client ID is set, it is sent with HTTP Basic (RFC 6749 §2.3.1).
POST /oauth/token HTTP/1.1
Host: api.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret) ← only if a client ID is set
grant_type=password&username=dealer42&password=••••••&scope=pycar.uploadHTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "eyJhbGciOi…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8…",
"scope": "pycar.upload"
}
OAuth 2.0, client credentials (grant client_credentials, §4.4):
grant_type=client_credentials&scope=pycar.upload
(client_id / client_secret in the Authorization: Basic header)Refresh: when the token expires, pycar uses the refresh token if one was returned, otherwise it logs in again:
grant_type=refresh_token&refresh_token=8xLOxBtZp8…HTTP Basic: every request carries Authorization: Basic base64(login:password); no token endpoint is needed.
Errors follow RFC 6749: return 400 invalid_grant or 401 invalid_client with error_description — pycar shows it to the user.
Endpoints
GET /v1/ping
Connection test (button “Test connection” in the app). Return 200 with a JSON object; the optional name field is displayed.
PUT /v1/vehicles/{vehicle_id}
Create or update the vehicle file. Return 200 or 201. vehicle_id is unique per phone and vehicle (install prefix + local folder). Fields may be empty strings; mileage_km may be null.
PUT /v1/vehicles/3f9a1c2e-Scan_2026-10-28_14-02-20 HTTP/1.1
Authorization: Bearer eyJhbGciOi…
Content-Type: application/json
{
"vehicle_id": "3f9a1c2e-Scan_2026-10-28_14-02-20",
"plate": "AB-123-CD",
"vin": "WMWXJ1C0X0TV12345",
"brand": "Mini",
"model": "Countryman ALL4",
"mileage_km": 48200,
"stock_ref": "VO-2026-0142",
"notes": "First owner, full service history",
"shot_plan": "standard",
"template": "Standard listing",
"decor": "studio_blanc",
"validated": true,
"created_at": "2026-10-28T14:02:20Z",
"hotspots": [
{ "hotspot_id": "6e05672e-…", "kind": "quality", "photo_id": "ext-02_left",
"x": 0.448, "y": 0.539, "zone": "front_left_door", "zone_name": "Front left door",
"note": "New paint", "detail_photo_id": "hs-6e05672e-…" }
],
"app": { "name": "pycar", "platform": "ios", "version": "1.0" }
}
PUT /v1/vehicles/{vehicle_id}/photos/{photo_id}
Create or replace a photo. Two parts: metadata (JSON) then file (JPEG). Check the SHA-256 and return 200/201; return 422 if the checksum does not match.
PUT /v1/vehicles/3f9a1c2e-Scan_2026-10-28_14-02-20/photos/studio-02_left-studio_blanc HTTP/1.1
Authorization: Bearer eyJhbGciOi…
Content-Type: multipart/form-data; boundary=pycar-7F3A…
--pycar-7F3A…
Content-Disposition: form-data; name="metadata"
Content-Type: application/json
{ "photo_id": "studio-02_left-studio_blanc", "vehicle_id": "3f9a1c2e-Scan_2026-10-28_14-02-20",
"kind": "studio", "view": "left", "decor": "studio_blanc", "source_photo_id": "ext-02_left",
"taken_at": "2026-10-28T17:34:02Z", "width": 3000, "height": 1688, "size": 861455,
"sha256": "9b1f…", "content_type": "image/jpeg", "plates_masked": true }
--pycar-7F3A…
Content-Disposition: form-data; name="file"; filename="studio-02_left-studio_blanc.jpg"
Content-Type: image/jpeg
<JPEG bytes>
--pycar-7F3A…--
POST /v1/vehicles/{vehicle_id}/complete
Sent after the last photo, with every photo ID of this transfer. Use it to trigger your import (publishing, DMS sync…).
POST /v1/vehicles/3f9a1c2e-Scan_2026-10-28_14-02-20/complete HTTP/1.1
Authorization: Bearer eyJhbGciOi…
Content-Type: application/json
{ "vehicle_id": "3f9a1c2e-Scan_2026-10-28_14-02-20",
"photo_ids": ["ext-02_left", "studio-02_left-studio_blanc", "det-07_jante", "pano-360", "vin"],
"sent_at": "2026-10-30T15:12:44Z" }
Photo types
| kind | Content | Extra metadata fields |
|---|---|---|
exterior | Original exterior shot (plates already masked) | view |
studio | Exterior shot placed in the vehicle’s studio backdrop | view, decor, source_photo_id |
detail | Detail photo from the checklist (rim, odometer…) | slot, slot_name |
hotspot | Detail photo attached to a quality or defect hotspot | hotspot_id, hotspot_kind, zone, zone_name, note |
panorama_360 | 360° photo (equirectangular JPEG, 2:1) | projection |
vin | Photo of the chassis number (VIN) | — |
Always present: photo_id, vehicle_id, kind, sha256, size, width, height, content_type (image/jpeg); usually taken_at (ISO 8601) and plates_masked.
Photo IDs: ext-… exterior, studio-…-{decor} studio, det-… detail, hs-{hotspot_id} hotspot, pano-360, vin.
Status codes and retries
| 2xx | Success. |
| 401 | Token rejected: pycar requests a new token once, then reports the error. |
| 413 | Too large: accept at least 25 MB per request (48 MP photos). |
| 422 | Invalid content or checksum: reported to the user. |
| 429 / 5xx | Retried 3 times with exponential back-off; Retry-After (seconds) is respected. |
| Network error | Retried 3 times, then reported. A new send resumes where it stopped. |
Security checklist
- HTTPS only, valid certificate (pycar refuses plain HTTP).
- Scope
pycar.uploadlimited to writing photos. - Short-lived access tokens (1 h) and revocable refresh tokens.
- Verify
sha256, the JPEG type and the size before storing. - Never trust
vehicle_id/photo_idas file paths without validation.
Reference server
A complete receiver in Python (Flask, ~130 lines) implementing OAuth (password, client credentials, refresh), HTTP Basic and every endpoint. Run it, put it behind HTTPS, and point pycar to it.
Test with curl
# 1. Token
curl -s https://api.example.com/oauth/token \
-d grant_type=password -d username=dealer42 -d password=secret -d scope=pycar.upload
# 2. Upload a photo the way pycar does
curl -X PUT https://api.example.com/v1/vehicles/test-1/photos/ext-01 \
-H "Authorization: Bearer $TOKEN" \
-F 'metadata={"kind":"exterior","sha256":"…"};type=application/json' \
-F 'file=@photo.jpg;type=image/jpeg'