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.
Authentication
Section titled “Authentication”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.
Quick start
Section titled “Quick start”- Stand up an HTTPS endpoint that answers
200to aPOST(for a first experiment, a webhook.site URL works). - Register it:
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"}'- CarStream immediately sends a signed
webhook.testevent to the URL. If it answers a2xxwithin 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}- Store the
secret— you need it to verify signatures. From now on every event isPOSTed to your URL.
Endpoints
Section titled “Endpoints”All endpoints are rooted at https://carstream.live/api/actions and take the
Authorization: Bearer cs_live_… header.
| Method | Path | What it does |
|---|---|---|
POST | /webhooks | Register 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 | /webhooks | List your hooks with status: active, consecutive_failures, disabled_reason, last_delivery_at, last_delivery_status. |
POST | /webhooks/{id}/enable | Re-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 | /cars | All 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://orhttps://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.
Reading car state
Section titled “Reading car state”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:
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:
| Field | Meaning |
|---|---|
access | owner for your cars, viewer for cars shared with you. |
vin, fuel_type, obd_protocol | Read from the car by the device; null until the first successful identification. |
vehicle_info | The decoded VIN (make, model, year, engine, …); null if the VIN was never decoded. Keys vary by what the decoder knows. |
device | The 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_percent | Last valid fuel reading (a car that never reported fuel: null). |
status.position | Last GPS fix with the speed at that moment; null before the first fix. |
status.active_dtc_codes | Currently 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.
The event envelope
Section titled “The event envelope”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" }}| Field | Meaning |
|---|---|
id | Unique event id (evt_ + 32 hex chars). Use it for idempotency — if you ever see the same id twice, process it once. |
type | Event type (see the catalog below). Also sent as the X-CarStream-Event header, so you can route without parsing the body. |
created_at | When the event was emitted, ISO 8601, UTC. |
car | The car the event is about — {id, name}. null only for webhook.test. |
data | Event-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.
Event catalog
Section titled “Event catalog”trip.started
Section titled “trip.started”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": {}}trip.ended
Section titled “trip.ended”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.
violation.speed
Section titled “violation.speed”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 }}violation.camera_speeding
Section titled “violation.camera_speeding”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 }}geofence.entered / geofence.exited
Section titled “geofence.entered / geofence.exited”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.
dtc.detected
Section titled “dtc.detected”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 }}webhook.test
Section titled “webhook.test”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" }}Deliveries
Section titled “Deliveries”Each event is a single POST to your URL:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | CarStream-Webhooks/1.0 |
X-CarStream-Event | The event type, e.g. trip.ended |
X-CarStream-Delivery | Unique delivery id (differs from the event id) |
X-CarStream-Signature | sha256=<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
2xxstatus. 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 /carswhenever you need to resynchronize.
Failures and auto-disable
Section titled “Failures and auto-disable”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 thewebhook.testhandshake 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.
Verifying signatures
Section titled “Verifying signatures”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 receivedimport crypto from "node:crypto";import express from "express";
const WEBHOOK_SECRET = "whsec_..."; // from the registration response
const app = express();// The signature covers the RAW body — capture it before any JSON parsing.app.post("/carstream/hook", express.raw({ type: "*/*" }), (req, res) => { const digest = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(req.body) .digest("hex"); const expected = Buffer.from(`sha256=${digest}`); const got = Buffer.from(req.get("X-CarStream-Signature") ?? ""); if (got.length !== expected.length || !crypto.timingSafeEqual(got, expected)) { return res.status(401).end(); }
const event = JSON.parse(req.body); if (event.type === "dtc.detected") { console.log(event.car.name, "reported", event.data.codes.join(", ")); } res.status(200).end(); // any 2xx marks the delivery as received});app.listen(3000);Receiver best practices
Section titled “Receiver best practices”- Answer
2xximmediately, 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_atif order matters to you. - Watch your hook’s status on the API page
while developing —
last_delivery_statusshows the exact HTTP status or error your endpoint produced.