Event Evidence
Post a domain event (a “symptom”) together with its video evidence in a single multipart request. The video is stored as an object and a small typed JSON envelope — carrying a reference to that object — is published to the event bus. This pattern is called media-by-reference: the binary never travels on the message bus. A namespaced event_type discriminator makes the endpoint extensible to many future symptoms without changing the API.
Endpoint
POST https://iot.streamhub.cl/v1/stream/eventsAuthentication
Requires a valid Bearer token with stream:write scope.
Authorization: Bearer <access_token>→ See Authentication > API Login to obtain a token.
Request
The request is multipart/form-data with two part names:
| Part | Content-Type | Required | Description |
|---|---|---|---|
envelope | application/json | Yes | The typed event envelope (exactly one part) |
media | video/mp4, video/h264 | No | Evidence file(s); send a part named media once per file (0..N) |
The file part is literally named
media. To attach several clips, repeat themediapart. Files are matched to the envelope’smedia[]hints by order, not by name.
Headers
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer <access_token> (scope stream:write) |
Content-Type | Yes | multipart/form-data (set automatically by most HTTP clients) |
Accept | Recommended | application/json |
X-Request-Id | No | Request id for tracing; echoed back on the response (one is generated if omitted) |
Body (EventEnvelope)
The envelope part is a typed, versioned JSON document. Send only the essentials — the server enriches the rest.
{
"schema_version": "1.0",
"event_type": "warehouse.restricted_zone.person",
"event_id": "evt-7f3a...",
"occurred_at": "2026-06-20T14:32:05Z",
"severity": "alert",
"subject": { "device_id": "cam-bodega-04", "zone_id": "zona-restringida-A" },
"detections": [ { "class": "person", "score": 0.97, "bbox": [0.10, 0.20, 0.30, 0.40] } ],
"media": [ { "ref": "clip" } ]
}
client_idis taken from the token — do not send it.media[]is optional: you may omit it entirely and just upload themediapart(s).
Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
schema_version | string | Yes | Envelope schema version. Currently "1.0". |
event_type | string | Yes | Namespaced symptom discriminator from the allowlist (see below). |
event_id | string | No | Idempotency key. If omitted, the server mints evt-<uuid>. |
occurred_at | string | Yes | ISO 8601 timestamp of when the symptom occurred. |
ended_at | string | No | ISO 8601 end of the window, for long-running symptoms. |
severity | string | No | info, warning, alert, or critical. |
subject | object | Yes | Who / where the event refers to (see below). |
detections | array | No | Detected objects (see below). |
media | array | No | Per-file hints, matched to the media parts by order (see below). |
attributes | object | No | Free-form, schema-per-event_type (forward-compatible). |
subject
| Field | Type | Required | Description |
|---|---|---|---|
device_id | string | Yes | Device identifier (e.g. the camera id). |
client_id | string | No | Normally derived from the token; override allowed. |
zone_id | string | No | Restricted/relevant zone id. |
Domain-specific identifiers (e.g. a vehicle’s license plate) belong in
attributes, notsubject, so the subject stays one coherent domain across all event types.
detections[]
| Field | Type | Required | Description |
|---|---|---|---|
class | string | No | Detected class, e.g. "person". (The wire field name is class.) |
score | number | No | Confidence score, 0..1. |
bbox | number[] | No | Normalized bounding box [x, y, w, h] in 0..1 (4 values, top-left origin). |
media[] (hints only)
All optional — everything else about the media is computed by the server.
| Field | Type | Required | Description |
|---|---|---|---|
ref | string | No | Informational label for the matching file part. |
duration_seconds | number | No | Clip duration, if the device knows it. |
checksum_sha256 | string | No | SHA-256 of the file; verified server-side if present. |
source | string | No | device or generated. Defaults to device. |
event_type allowlist
event_type must be a registered symptom. Unknown values are rejected before any storage or publish.
| Value | Description |
|---|---|
warehouse.restricted_zone.person | A person detected inside a restricted warehouse zone. |
The allowlist grows additively — new symptoms are onboarded without an API change. Contact the ModularIoT team to register a new
event_type.
Response
Accepted (202)
A lean acknowledgment. The storage location and playback URL are intentionally not exposed by the ingest API.
{
"status": "accepted",
"event_id": "evt-7f3a...",
"media_count": 1
}Repeated requests with the same event_id are idempotent: the second call returns the cached response with an Idempotent-Replay: true header — no duplicate storage or publish.
Validation Error (400)
A required field is missing or malformed.
{
"status": "error",
"code": "validation_error",
"details": { "eventType": "event_type is required" }
}Unknown event_type (400)
{
"status": "error",
"code": "unknown_event_type",
"details": { "event_type": "Unsupported event_type: warehouse.not_a_real_symptom" }
}Checksum Mismatch (400)
Returned when a checksum_sha256 hint does not match the uploaded file.
{
"status": "error",
"code": "checksum_mismatch",
"details": { "media[0]": "sha256 mismatch" }
}Unauthorized (401) / Forbidden (403)
401 when the token is missing or expired; 403 when the token is valid but lacks the stream:write scope.
How the Media is Stored (media-by-reference)
- The uploaded video is stored as an object, isolated per
client_id(multi-tenant via the token). - A small JSON envelope, carrying a reference to the stored object, is published to the event bus for downstream consumers (timeline, warehouse projection, playback).
- The ingest response never exposes the storage location or playback URL — those travel only in the published envelope.
This keeps the request small and the binary off the message bus, while consumers still reach the exact clip by reference.
Postman Collection
The collection includes:
- Authentication — pre-configured Auth0 login that saves the token automatically
- Restricted Zone Person — canonical envelope + clip
- Minimal — required fields only, clip only
- With Hints —
duration_seconds/source/checksum_sha256 - Multiple Clips — two
mediaparts - Idempotent Replay — fixed
event_id - Unknown event_type / Missing Required Field — the
400cases
Open each request’s Body tab and attach a real
.mp4to themediafile field before sending. The client must have thestream:writescope.
Complete Examples
curl (multipart)
# 1) Get a token (must include the stream:write scope)
TOKEN=$(curl -s --request POST \
--url https://api.microboxlabs.com/api/v1/login \
--header 'Content-Type: application/json' \
--data '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"audience": "https://iot.streamhub.cl/v1/asset/track",
"grant_type": "client_credentials"
}' | jq -r '.access_token')
# 2) Post the event + clip. Note the per-part content types.
curl --request POST \
--url https://iot.streamhub.cl/v1/stream/events \
--header "Authorization: Bearer $TOKEN" \
--header 'Accept: application/json' \
--header 'X-Request-Id: req-001' \
--form 'envelope={"schema_version":"1.0","event_type":"warehouse.restricted_zone.person","occurred_at":"2026-06-20T14:32:05Z","severity":"alert","subject":{"device_id":"cam-bodega-04","zone_id":"zona-restringida-A"},"detections":[{"class":"person","score":0.97,"bbox":[0.10,0.20,0.30,0.40]}]};type=application/json' \
--form 'media=@./restricted-zone-person.mp4;type=video/mp4'Node.js (fetch)
import { readFile } from "node:fs/promises";
const envelope = {
schema_version: "1.0",
event_type: "warehouse.restricted_zone.person",
occurred_at: "2026-06-20T14:32:05Z",
severity: "alert",
subject: { device_id: "cam-bodega-04", zone_id: "zona-restringida-A" },
detections: [{ class: "person", score: 0.97, bbox: [0.1, 0.2, 0.3, 0.4] }],
};
const form = new FormData();
form.append("envelope", new Blob([JSON.stringify(envelope)], { type: "application/json" }));
form.append("media", new Blob([await readFile("restricted-zone-person.mp4")], { type: "video/mp4" }), "clip.mp4");
const res = await fetch("https://iot.streamhub.cl/v1/stream/events", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"X-Request-Id": crypto.randomUUID(),
// Do NOT set Content-Type — fetch adds the multipart boundary for you.
},
body: form,
});
if (!res.ok) throw new Error(`stream/events ${res.status}: ${await res.text()}`);
console.log(await res.json()); // { status: "accepted", event_id, media_count }Python (requests)
import json, uuid, requests
envelope = {
"schema_version": "1.0",
"event_type": "warehouse.restricted_zone.person",
"occurred_at": "2026-06-20T14:32:05Z",
"severity": "alert",
"subject": {"device_id": "cam-bodega-04", "zone_id": "zona-restringida-A"},
"detections": [{"class": "person", "score": 0.97, "bbox": [0.10, 0.20, 0.30, 0.40]}],
}
with open("restricted-zone-person.mp4", "rb") as clip:
files = {
"envelope": (None, json.dumps(envelope), "application/json"),
"media": ("clip.mp4", clip, "video/mp4"),
}
res = requests.post(
"https://iot.streamhub.cl/v1/stream/events",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"X-Request-Id": str(uuid.uuid4()),
},
files=files,
timeout=30,
)
res.raise_for_status()
print(res.json()) # {'status': 'accepted', 'event_id': '...', 'media_count': 1}For multiple clips, use a list of tuples (a dict can’t repeat the media key):
files = [
("envelope", (None, json.dumps(envelope), "application/json")),
("media", ("front.mp4", open("front.mp4", "rb"), "video/mp4")),
("media", ("aisle.mp4", open("aisle.mp4", "rb"), "video/mp4")),
]Java (HttpClient)
HttpClient has no built-in multipart writer, so build the body from ordered byte chunks:
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.util.*;
String boundary = "----miot" + UUID.randomUUID();
String envelope = """
{"schema_version":"1.0","event_type":"warehouse.restricted_zone.person",
"occurred_at":"2026-06-20T14:32:05Z","severity":"alert",
"subject":{"device_id":"cam-bodega-04","zone_id":"zona-restringida-A"},
"detections":[{"class":"person","score":0.97,"bbox":[0.10,0.20,0.30,0.40]}]}
""";
List<byte[]> parts = new ArrayList<>();
parts.add(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"envelope\"\r\n"
+ "Content-Type: application/json\r\n\r\n" + envelope + "\r\n").getBytes());
parts.add(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"media\"; filename=\"clip.mp4\"\r\n"
+ "Content-Type: video/mp4\r\n\r\n").getBytes());
parts.add(Files.readAllBytes(Path.of("restricted-zone-person.mp4")));
parts.add(("\r\n--" + boundary + "--\r\n").getBytes());
HttpRequest req = HttpRequest.newBuilder(URI.create("https://iot.streamhub.cl/v1/stream/events"))
.header("Authorization", "Bearer " + token)
.header("Accept", "application/json")
.header("X-Request-Id", UUID.randomUUID().toString())
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArrays(parts))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 300) throw new RuntimeException("stream/events " + res.statusCode() + ": " + res.body());Notes
- Multiple clips: repeat the
mediapart and add one hint per file inmedia[](matched by order). The responsemedia_countreflects how many were stored. - Idempotency: set a stable
event_idto make retries safe — duplicates are de-duplicated for 24 hours. - Max upload size: up to 256 MB per request. For larger or many clips, split across events.
- Checksums (optional): include
checksum_sha256in amedia[]hint to have the server verify the upload (shasum -a 256 clip.mp4). A mismatch returns400 checksum_mismatch.