Skip to content

Actions API — webhooks

The Actions API pushes CarStream events to your own systems in real time. Whenever CarStream sends a push notification to your phone — a trip starting or ending, a rule you created firing, your car crossing a geofence, a new fault code appearing — the same event is also delivered as an HTTP POST to every webhook you have registered.

Webhooks fire for the same audience as the push: the car’s owner and everyone the car is shared with each deliver to their own registered hooks. You do not need a phone or a registered push token — webhooks are independent of mobile devices.

Every request authenticates with your personal Actions API token as a bearer header:

Authorization: Bearer cs_live_…
  • Get the token from the API page — it is minted on your first visit.
  • The token never expires.
  • Regenerate (on the same page) rotates it: the old token stops working immediately, and your registered webhooks are untouched — deliveries are authenticated by each hook’s own signing secret, not by the API token.
  1. Stand up an HTTPS endpoint that answers 200 to a POST (for a first experiment, a webhook.site URL works).
  2. Register it:
Terminal window
curl -X POST https://carstream.live/api/actions/webhooks \
-H "Authorization: Bearer cs_live_…" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/carstream/hook"}'
  1. CarStream immediately sends a signed webhook.test event to the URL. If it answers a 2xx within 10 seconds, the hook is registered and the response includes its signing secret:
{
"id": "3f8a1c2e-6b4d-4f0a-9c21-7d5e8a9b0c34",
"url": "https://example.com/carstream/hook",
"secret": "whsec_…",
"active": true,
"created_at": 1724769730.4
}
  1. Store the secret — you need it to verify signatures. From now on every event is POSTed to your URL.

All endpoints are rooted at https://carstream.live/api/actions and take the Authorization: Bearer cs_live_… header.

MethodPathWhat it does
POST/webhooksRegister a URL. Body: {"url": "https://…"}. Runs the webhook.test handshake; 422 if the URL fails vetting or doesn’t answer 2xx in 10 s. Returns the hook with its secret.
GET/webhooksList your hooks with status: active, consecutive_failures, disabled_reason, last_delivery_at, last_delivery_status.
POST/webhooks/{id}/enableRe-arm a disabled hook. Re-runs the same handshake first, so a still-broken receiver can’t re-enter the delivery pool.
DELETE/webhooks/{id}Delete the hook. Deliveries stop immediately.
GET/carsAll cars visible to you — everything CarStream knows about each (see Reading car state).
GET/cars/{id}One car by id. 404 covers both “doesn’t exist” and “not yours”.

Webhook URLs must satisfy two rules:

  • http:// or https:// scheme (use HTTPS for anything real);
  • the hostname must resolve to a public address — loopback, private (RFC 1918), link-local and other reserved ranges are rejected at registration time.

You can register up to 10 webhooks.

Webhooks are the push side; GET /cars is the pull side — the same “everything we know” object for every car your account can see (owned and shared with you). Use it to bootstrap your integration’s state, or to re-read after a missed delivery:

Terminal window
curl https://carstream.live/api/actions/cars \
-H "Authorization: Bearer cs_live_…"
{
"cars": [
{
"id": "1b2f7c1e-93d4-4c88-b1a0-2f6e8d9a4c55",
"name": "BMW 530d",
"access": "owner",
"created_at": 1712841600.0,
"vin": "WBAJC51090B330218",
"fuel_type": "diesel",
"obd_protocol": "ISO 15765-4 (CAN 11/500)",
"tank_size_liters": 66.0,
"vehicle_info": {
"make": "BMW",
"model": "530d",
"year": "2019",
"engine": "3.0L L6 DIESEL"
},
"device": {
"serial": "cs-a1b2c3d4",
"online": true,
"last_seen": 1724769730.4
},
"status": {
"fuel_percent": 62.5,
"position": {
"lat": 50.4501,
"lon": 30.5234,
"speed_kmh": 47.0,
"updated_at": 1724769728.9
},
"active_dtc": 1,
"active_dtc_codes": ["P0420"],
"mil": true
}
}
]
}

Field notes:

FieldMeaning
accessowner for your cars, viewer for cars shared with you.
vin, fuel_type, obd_protocolRead from the car by the device; null until the first successful identification.
vehicle_infoThe decoded VIN (make, model, year, engine, …); null if the VIN was never decoded. Keys vary by what the decoder knows.
deviceThe paired CarStream Unit; null if the car has no device right now. online uses the same 30-second freshness rule as the app.
status.fuel_percentLast valid fuel reading (a car that never reported fuel: null).
status.positionLast GPS fix with the speed at that moment; null before the first fix.
status.active_dtc_codesCurrently open fault codes; mil is the check-engine lamp.

Timestamps here are Unix epoch seconds, like everywhere else in the Actions API outside the event envelope’s created_at.

GET /cars/{id} returns the same object for one car.

Every delivery is one JSON object:

{
"id": "evt_9f2c41d0a8b34c6d9e21f7a3b5d80c14",
"type": "trip.ended",
"created_at": "2026-08-27T17:42:10+00:00",
"car": { "id": "1b2f7c1e-93d4-4c88-b1a0-2f6e8d9a4c55", "name": "BMW 530d" },
"data": { "…": "event-specific fields" }
}
FieldMeaning
idUnique event id (evt_ + 32 hex chars). Use it for idempotency — if you ever see the same id twice, process it once.
typeEvent type (see the catalog below). Also sent as the X-CarStream-Event header, so you can route without parsing the body.
created_atWhen the event was emitted, ISO 8601, UTC.
carThe car the event is about — {id, name}. null only for webhook.test.
dataEvent-specific payload, documented per type below.

Timestamps inside data (fired_at, detected_at) are Unix epoch seconds (float) — they come straight from the device that observed the event.

The car started moving. data is empty — the car object is the information.

{
"id": "evt_9f2c41d0a8b34c6d9e21f7a3b5d80c14",
"type": "trip.started",
"created_at": "2026-08-27T17:10:22+00:00",
"car": { "id": "1b2f7c1e-93d4-4c88-b1a0-2f6e8d9a4c55", "name": "BMW 530d" },
"data": {}
}

A trip finished and its stats are computed.

{
"id": "evt_2c1e7a90bb614f0d8332ac54fd0e91aa",
"type": "trip.ended",
"created_at": "2026-08-27T17:42:10+00:00",
"car": { "id": "1b2f7c1e-93d4-4c88-b1a0-2f6e8d9a4c55", "name": "BMW 530d" },
"data": {
"trip_id": 8412,
"distance_km": 23.4,
"max_speed_kmh": 92,
"avg_speed_kmh": 47,
"duration_min": 31.5,
"start_time": "2026-08-27T17:10:20+00:00",
"end_time": "2026-08-27T17:41:50+00:00"
}
}

trip_id (and any stat) can be null in the rare case the trip could not be resolved to a finalized record — you then still get the event with duration_min when known.

A speed-limit rule you created fired. Delivered only to the rule’s creator.

{
"id": "evt_77d0b2c94aa14b7f8e02cd13ef559b21",
"type": "violation.speed",
"created_at": "2026-08-27T17:20:31+00:00",
"car": { "id": "1b2f7c1e-93d4-4c88-b1a0-2f6e8d9a4c55", "name": "BMW 530d" },
"data": {
"violation_id": 5121,
"rule_id": 18,
"rule_name": "Over 90 in the city",
"violation_type": "speed",
"value": {
"speed": 96.4,
"threshold": 90,
"lat": 50.4501,
"lon": 30.5234
},
"fired_at": 1724769730.4
}
}

The car passed a speed camera above your rule’s threshold (speed_limit + your configured offset). The speed is the closest-approach speed — what a radar would have measured.

{
"id": "evt_b8f4e1a2cd374d569910aa72c3e8f0d5",
"type": "violation.camera_speeding",
"created_at": "2026-08-27T17:25:03+00:00",
"car": { "id": "1b2f7c1e-93d4-4c88-b1a0-2f6e8d9a4c55", "name": "BMW 530d" },
"data": {
"violation_id": 5122,
"rule_id": 21,
"rule_name": "Speed cameras",
"violation_type": "camera_speeding",
"value": {
"speed": 84.0,
"threshold": 70.0,
"speed_limit": 50,
"offset": 20,
"cam_id": "UA-2214",
"cam_lat": 50.4477,
"cam_lon": 30.452,
"highway_class": "secondary",
"address": "просп. Перемоги, 57",
"lat": 50.4476,
"lon": 30.4514,
"dist_m": 42.5
},
"fired_at": 1724769730.4
}
}

The car crossed a geofence rule’s boundary. value carries the car’s position, the fence’s center and radius, and the distance from the center at the moment of the crossing.

{
"id": "evt_4a6c0d2e8bb14f77a3c9e51d20f6b843",
"type": "geofence.entered",
"created_at": "2026-08-27T18:02:44+00:00",
"car": { "id": "1b2f7c1e-93d4-4c88-b1a0-2f6e8d9a4c55", "name": "BMW 530d" },
"data": {
"violation_id": 5123,
"rule_id": 12,
"rule_name": "Home",
"violation_type": "geofence_enter",
"value": {
"lat": 50.4021,
"lon": 30.6521,
"fence_lat": 50.4025,
"fence_lon": 30.6512,
"radius_m": 250,
"distance_m": 180.3
},
"fired_at": 1724769730.4
}
}

geofence.exited is identical except type, violation_type: "geofence_exit", and a distance_m greater than radius_m.

New diagnostic trouble codes appeared. Codes are aggregated per read — one event may carry several related codes. Each code has a 24-hour notification cooldown, matching the push behaviour.

{
"id": "evt_d1e9a4b7f2c94e0286f3ab15c07d8e46",
"type": "dtc.detected",
"created_at": "2026-08-27T18:15:09+00:00",
"car": { "id": "1b2f7c1e-93d4-4c88-b1a0-2f6e8d9a4c55", "name": "BMW 530d" },
"data": {
"codes": ["P0301", "P0420"],
"detected_at": 1724769730.4
}
}

Sent once when a webhook is registered or re-enabled — the handshake your endpoint must answer with a 2xx. The only event where car is null.

{
"id": "evt_5c2f8e0a1db64c3f9a47b0e6d92c1f58",
"type": "webhook.test",
"created_at": "2026-08-27T18:20:00+00:00",
"car": null,
"data": { "message": "CarStream webhook verification" }
}

Each event is a single POST to your URL:

HeaderValue
Content-Typeapplication/json
User-AgentCarStream-Webhooks/1.0
X-CarStream-EventThe event type, e.g. trip.ended
X-CarStream-DeliveryUnique delivery id (differs from the event id)
X-CarStream-Signaturesha256=<hex HMAC-SHA256 of the raw body, keyed with your hook's secret>

Delivery rules:

  • Timeout: 10 seconds. Answer fast — do the real work asynchronously.
  • Success: any 2xx status. Everything else — including timeouts and connection errors — counts as a failure.
  • Redirects are not followed. Register the final URL.
  • No per-event retries. Events keep flowing; a missed delivery is missed — re-read current state from GET /cars whenever you need to resynchronize.

Every hook carries a consecutive-failure counter:

  • a failed delivery increments it; a successful one resets it to zero;
  • at 3 consecutive failures the hook is disabled automatically and stops receiving events;
  • re-enable it on the API page or via POST /api/actions/webhooks/{id}/enable — both re-run the webhook.test handshake first.

The GET /webhooks response (and the API page) shows consecutive_failures, disabled_reason, last_delivery_at and last_delivery_status so you can see exactly what happened.

Always verify X-CarStream-Signature before trusting a delivery: it proves the request came from CarStream and that the body was not tampered with. Compute HMAC-SHA256 over the raw request bytes with your hook’s whsec_ secret and compare with a constant-time comparison.

import hashlib, hmac
from fastapi import FastAPI, Header, HTTPException, Request
WEBHOOK_SECRET = "whsec_..." # from the registration response
app = FastAPI()
@app.post("/carstream/hook")
async def carstream_hook(
request: Request,
x_carstream_signature: str = Header(""),
x_carstream_event: str = Header(""),
):
body = await request.body()
digest = hmac.new(WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(x_carstream_signature, f"sha256={digest}"):
raise HTTPException(401, "bad signature")
event = await request.json()
if x_carstream_event == "trip.ended":
print(event["car"]["name"], "drove", event["data"]["distance_km"], "km")
return {"ok": True} # any 2xx marks the delivery as received
  • Answer 2xx immediately, queue the real work. A slow handler burns the 10-second window and racks up failures.
  • Verify the signature on every request — your endpoint is public.
  • Deduplicate by event id. Delivery is at-most-once per hook, but your own infrastructure (load balancers, retries in front of your app) can duplicate requests.
  • Don’t rely on ordering. Events are dispatched concurrently; use created_at / fired_at if order matters to you.
  • Watch your hook’s status on the API page while developing — last_delivery_status shows the exact HTTP status or error your endpoint produced.