Skip to content

Xylolabs API Reference

Version: v1 Last Updated: 2026-03-22


Table of Contents

  1. Overview
  2. Authentication
  3. Facilities
  4. Users
  5. API Keys
  6. Devices
  7. Audio Upload
  8. Audio Streaming
  9. Transcode Jobs
  10. Tags
  11. Metadata Ingest
  12. Metadata Query
  13. System Configuration
  14. Dashboard Stats
  15. Health
  16. XMBP Protocol Reference
  17. RBAC Reference
  18. Error Responses
  19. Data Models
  20. Pagination
  21. Example Workflows
  22. LTE Cat-M1 Integration Guide

1. Overview

What is Xylolabs API?

Xylolabs API handles audio recording uploads and real-time sensor metadata from IoT and embedded devices. It gives you:

  • Audio Upload and Transcoding -- Upload high-resolution audio files (WAV, FLAC, etc.) and they're automatically transcoded into web-playable formats (Opus/WebM, AAC/MP4).
  • Real-time Metadata Streaming -- Stream sensor data from embedded devices using the compact XMBP binary protocol over HTTP or WebSocket.
  • Multi-tenant Facility Isolation -- All data is scoped to facilities with role-based access control.
  • Admin Dashboard API -- Full CRUD for facilities, users, API keys, devices, and system configuration.

Quick Start

Get up and running in five steps:

  1. Log in with POST /api/auth/login to get a JWT token.
  2. Create a facility with POST /api/v1/facilities (Super Admin only).
  3. Generate an API key with POST /api/v1/api-keys for your devices.
  4. Upload audio from a device with POST /api/v1/uploads using the API key.
  5. Query data through the dashboard endpoints using your JWT.

If you're deploying over Cat-M1 cellular, read Section 22 first for bandwidth planning.

Architecture

Embedded Device (Pico, ESP32, Cat-M1 modem, etc.)
  |
  +-- Audio Upload (multipart HTTP) ---> S3 (original) ---> Auto-transcode ---> S3 (web copy)
  |
  +-- Metadata Streaming (XMBP) -------> IngestManager ---> PostgreSQL + S3 chunks

LTE Cat-M1 Device (nRF9160, BG770A, etc.)
  |
  +-- HTTP POST (XMBP batches) ---------> IngestManager ---> PostgreSQL + S3 chunks
  |     (~37 KB/s uplink budget)
  +-- Audio Upload (chunked) -----------> S3 (original) ---> Auto-transcode
        (large files sent in background)

Admin Dashboard (Web)
  |
  +-- JWT Auth ------> REST API --------> PostgreSQL / S3

Base URLs

Environment Base URL
Production API https://api.xylolabs.com/api
Admin Dashboard https://admin.api.xylolabs.com
Development http://localhost:3000/api

Authentication Methods

Method Header Used By Endpoints
JWT Bearer Token Authorization: Bearer <token> Dashboard / Web clients All /api/v1/* protected routes
API Key X-Api-Key: xk_... Embedded devices Upload, Ingest

Cat-M1 devices typically use API key authentication since they don't need dashboard access.


2. Authentication

POST /api/auth/login

Authenticate with email and password. Returns a JWT access token and refresh token pair.

Auth: None Role: None

Request Body:

{
  "email": "minseok.jeon@xylolabs.com",
  "password": "your-password"
}
Field Type Required Description
email string Yes User email address
password string Yes User password

Response 200 OK:

{
  // Short-lived token for API calls (expires in 15 minutes)
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  // Long-lived token to get new access tokens without re-entering credentials
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {
    "id": "a3c1e7b2-4f8d-4a12-b6e9-1d5f3a7c9e20",
    "email": "minseok.jeon@xylolabs.com",
    "display_name": "전민석",
    "role": "super_admin",
    "facility_id": null       // null because super_admins aren't tied to one facility
  }
}

Error Cases:

Status Condition
401 Unauthorized Invalid email or password
401 Unauthorized Account is inactive

curl:

# Log in and capture the access token for later use
curl -X POST https://api.xylolabs.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "minseok.jeon@xylolabs.com",
    "password": "your-password"
  }'

POST /api/auth/refresh

Trade a valid refresh token for a fresh access token and a rotated refresh token. Call this when your access token expires instead of asking the user to log in again.

Auth: None Role: None

Request Body:

{
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Field Type Required Description
refresh_token string Yes The refresh token from login

Response 200 OK:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Error Cases:

Status Condition
401 Unauthorized Invalid or expired refresh token
401 Unauthorized User not found or inactive

curl:

# Silently refresh the token before it expires
curl -X POST https://api.xylolabs.com/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"eyJhbGciOiJIUzI1NiIs..."}'

GET /api/auth/me

Returns the profile of whoever owns the current JWT.

Auth: JWT Bearer Token Role: Any authenticated user

Response 200 OK:

{
  "id": "a3c1e7b2-4f8d-4a12-b6e9-1d5f3a7c9e20",
  "email": "minseok.jeon@xylolabs.com",
  "display_name": "전민석",
  "role": "super_admin",
  "facility_id": null
}

Error Cases:

Status Condition
401 Unauthorized Missing or invalid JWT

curl:

# Check which user the current token belongs to
curl https://api.xylolabs.com/api/auth/me \
  -H "Authorization: Bearer $TOKEN"

3. Facilities

Facilities are the top-level organizational unit. All data -- users, devices, uploads, sessions -- belongs to a facility. Only Super Admins can create or manage facilities.

GET /api/v1/facilities

Returns every facility in the system.

Auth: JWT Bearer Token Role: super_admin

Response 200 OK:

[
  {
    "id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
    "name": "페리지에어로스페이스 옥천공장",
    "slug": "perigee-okcheon",
    "description": "옥천 생산공장 음향 모니터링",
    "is_active": true,
    "created_at": "2025-01-15T09:00:00Z",
    "updated_at": "2025-03-22T14:30:00Z"
  },
  {
    "id": "c9e2a3b5-7d1f-4c68-b4a7-2e8f6d9c1b35",
    "name": "페리지에어로스페이스 미래기술연구소",
    "slug": "perigee-futuretech",
    "description": "미래기술연구소 센서 데이터 수집",
    "is_active": true,
    "created_at": "2025-02-10T11:00:00Z",
    "updated_at": "2025-02-10T11:00:00Z"
  }
]

curl:

# List all facilities (super_admin only)
curl https://api.xylolabs.com/api/v1/facilities \
  -H "Authorization: Bearer $TOKEN"

POST /api/v1/facilities

Creates a new facility.

Auth: JWT Bearer Token Role: super_admin

Request Body:

{
  "name": "페리지에어로스페이스 옥천공장",
  "slug": "perigee-okcheon",
  "description": "옥천 생산공장 음향 모니터링"
}
Field Type Required Description
name string Yes Facility display name
slug string Yes Unique URL-friendly identifier
description string No Optional description

Response 200 OK:

{
  "id": "d5f8c2e1-3a9b-4d72-8c6e-4b1a7f9d3e56",
  "name": "페리지에어로스페이스 옥천공장",
  "slug": "perigee-okcheon",
  "description": "옥천 생산공장 음향 모니터링",
  "is_active": true,
  "created_at": "2025-06-15T10:00:00Z",
  "updated_at": "2025-06-15T10:00:00Z"
}

Error Cases:

Status Condition
403 Forbidden User is not a Super Admin
409 Conflict Facility with the same slug already exists

curl:

# Create a new facility
curl -X POST https://api.xylolabs.com/api/v1/facilities \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "페리지에어로스페이스 옥천공장",
    "slug": "perigee-okcheon",
    "description": "옥천 생산공장 음향 모니터링"
  }'

GET /api/v1/facilities/{id}

Fetches a single facility by its ID.

Auth: JWT Bearer Token Role: super_admin

Path Parameters:

Parameter Type Description
id UUID Facility ID

Response 200 OK:

{
  "id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
  "name": "페리지에어로스페이스 옥천공장",
  "slug": "perigee-okcheon",
  "description": "옥천 생산공장 음향 모니터링",
  "is_active": true,
  "created_at": "2025-01-15T09:00:00Z",
  "updated_at": "2025-03-22T14:30:00Z"
}

Error Cases:

Status Condition
403 Forbidden User is not a Super Admin
404 Not Found Facility not found

curl:

curl https://api.xylolabs.com/api/v1/facilities/b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14 \
  -H "Authorization: Bearer $TOKEN"

PATCH /api/v1/facilities/{id}

Updates a facility. Only include the fields you want to change.

Auth: JWT Bearer Token Role: super_admin

Path Parameters:

Parameter Type Description
id UUID Facility ID

Request Body:

{
  "name": "페리지에어로스페이스 옥천공장",
  "description": "옥천 생산공장 음향 모니터링"
}
Field Type Required Description
name string No New facility name
description string No New description
is_active boolean No Enable/disable facility

Response 200 OK: Updated facility object (same shape as GET).

Error Cases:

Status Condition
403 Forbidden User is not a Super Admin
404 Not Found Facility not found

curl:

# Update facility name and description
curl -X PATCH https://api.xylolabs.com/api/v1/facilities/b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"페리지에어로스페이스 옥천공장"}'

DELETE /api/v1/facilities/{id}

Deletes a facility and all its associated data.

Auth: JWT Bearer Token Role: super_admin

Path Parameters:

Parameter Type Description
id UUID Facility ID

Response 204 No Content

Error Cases:

Status Condition
403 Forbidden User is not a Super Admin
404 Not Found Facility not found

curl:

# Permanently delete a facility -- this can't be undone
curl -X DELETE https://api.xylolabs.com/api/v1/facilities/d5f8c2e1-3a9b-4d72-8c6e-4b1a7f9d3e56 \
  -H "Authorization: Bearer $TOKEN"

4. Users

Users belong to a facility and have roles that control what they can see and do. Facility Admins manage users within their own facility. Super Admins can manage everyone.

GET /api/v1/users

Returns a list of users. Facility Admins only see users in their own facility; Super Admins see all users across the system.

Auth: JWT Bearer Token Role: facility_admin or higher

Response 200 OK:

[
  {
    "id": "e4b9c6d2-1f3a-4e87-9b5c-8d2a6f4e1c73",
    "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
    "email": "minseok.jeon@xylolabs.com",
    "display_name": "전민석",
    "role": "facility_admin",
    "is_active": true,
    "last_login_at": "2025-06-15T08:30:00Z",
    "created_at": "2025-01-15T09:00:00Z",
    "updated_at": "2025-06-15T08:30:00Z"
  },
  {
    "id": "f7a2d8e5-4c6b-4f19-a3d7-5e9c1b8a2f46",
    "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
    "email": "dongyun.shin@xylolabs.com",
    "display_name": "신동윤",
    "role": "user",
    "is_active": true,
    "last_login_at": "2025-06-14T17:45:00Z",
    "created_at": "2025-03-01T10:00:00Z",
    "updated_at": "2025-06-14T17:45:00Z"
  }
]

curl:

curl https://api.xylolabs.com/api/v1/users \
  -H "Authorization: Bearer $TOKEN"

POST /api/v1/users

Creates a new user account.

Auth: JWT Bearer Token Role: facility_admin or higher

Request Body:

{
  "email": "minseok.jeon@xylolabs.com",
  "password": "Kj8#mP2x!vNq",
  "display_name": "전민석",
  "role": "user",
  "facility_id": "c9e2a3b5-7d1f-4c68-b4a7-2e8f6d9c1b35"
}
Field Type Required Description
email string Yes Unique email address
password string Yes User password (hashed with Argon2)
display_name string Yes Display name
role string Yes One of: super_admin, facility_admin, user
facility_id UUID No Facility to assign (Super Admin only; Facility Admins auto-assign their facility)

Response 200 OK: The created user object (same shape as the list response).

Error Cases:

Status Condition
403 Forbidden Insufficient role
409 Conflict Email already exists

curl:

# Add a read-only user to the facility
curl -X POST https://api.xylolabs.com/api/v1/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "dongyun.shin@xylolabs.com",
    "password": "Kj8#mP2x!vNq",
    "display_name": "신동윤",
    "role": "user",
    "facility_id": "c9e2a3b5-7d1f-4c68-b4a7-2e8f6d9c1b35"
  }'

GET /api/v1/users/{id}

Fetches a single user by ID.

Auth: JWT Bearer Token Role: facility_admin or higher

Path Parameters:

Parameter Type Description
id UUID User ID

Response 200 OK: User object.

Error Cases:

Status Condition
403 Forbidden Insufficient role or cross-facility access
404 Not Found User not found

curl:

curl https://api.xylolabs.com/api/v1/users/e4b9c6d2-1f3a-4e87-9b5c-8d2a6f4e1c73 \
  -H "Authorization: Bearer $TOKEN"

PATCH /api/v1/users/{id}

Updates a user. Only include the fields you want to change.

Auth: JWT Bearer Token Role: facility_admin or higher

Path Parameters:

Parameter Type Description
id UUID User ID

Request Body:

{
  "display_name": "전민석",
  "role": "facility_admin"
}
Field Type Required Description
display_name string No New display name
role string No New role
is_active boolean No Enable/disable user

Response 200 OK: Updated user object.

Error Cases:

Status Condition
403 Forbidden Insufficient role or cross-facility access
404 Not Found User not found

curl:

# Promote user to facility admin
curl -X PATCH https://api.xylolabs.com/api/v1/users/e4b9c6d2-1f3a-4e87-9b5c-8d2a6f4e1c73 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role":"facility_admin"}'

DELETE /api/v1/users/{id}

Soft-deletes a user by setting is_active to false. The account record stays in the database but the user can no longer log in.

Auth: JWT Bearer Token Role: facility_admin or higher

Path Parameters:

Parameter Type Description
id UUID User ID

Response 204 No Content

Error Cases:

Status Condition
403 Forbidden Insufficient role or cross-facility access
404 Not Found User not found

curl:

# Deactivate a user (soft delete -- can be re-enabled via PATCH)
curl -X DELETE https://api.xylolabs.com/api/v1/users/f7a2d8e5-4c6b-4f19-a3d7-5e9c1b8a2f46 \
  -H "Authorization: Bearer $TOKEN"

5. API Keys

API keys let embedded devices authenticate for audio uploads and metadata ingestion. Each key is scoped to a single facility and can have optional permission scopes and an expiration date. The plaintext key is shown only once at creation, is not stored retrievably, and must be saved immediately.

GET /api/v1/api-keys

Lists all API keys for the current user's facility.

Auth: JWT Bearer Token Role: facility_admin or higher

Response 200 OK:

[
  {
    "id": "1a8b3c5d-7e2f-4a69-b1c4-3d5e7f9a2b48",
    "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
    "key_prefix": "xk_t4r7",
    "name": "Turbine Hall Sensor Array",
    "scopes": ["upload", "ingest"],
    "is_active": true,
    "expires_at": "2026-01-01T00:00:00Z",
    "last_used_at": "2025-06-15T10:30:00Z",
    "created_by": "e4b9c6d2-1f3a-4e87-9b5c-8d2a6f4e1c73",
    "created_at": "2025-06-01T09:00:00Z"
  },
  {
    "id": "2b9c4d6e-8f3a-4b71-c2d5-4e6f8a1b3c59",
    "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
    "key_prefix": "xk_m9p2",
    "name": "Rooftop Weather Station",
    "scopes": ["ingest"],
    "is_active": true,
    "expires_at": null,
    "last_used_at": "2025-06-15T09:15:00Z",
    "created_by": "e4b9c6d2-1f3a-4e87-9b5c-8d2a6f4e1c73",
    "created_at": "2025-05-20T14:00:00Z"
  }
]

curl:

# See all API keys for your facility (only the key prefix is shown here)
curl https://api.xylolabs.com/api/v1/api-keys \
  -H "Authorization: Bearer $TOKEN"

POST /api/v1/api-keys

Creates a new API key. The plaintext key appears only in this response -- store it in your device firmware or a secrets manager right away. You won't be able to retrieve it later.

Auth: JWT Bearer Token Role: facility_admin or higher

Request Body:

{
  "name": "Underwater Hydrophone #3",
  "scopes": ["upload", "ingest"],
  "expires_at": "2026-06-01T00:00:00Z"
}
Field Type Required Description
name string Yes Descriptive name for the key
scopes string[] No Permission scopes (defaults to all)
expires_at ISO 8601 No Expiration date (null = never expires)

Response 200 OK:

{
  "id": "3c1d5e7f-9a4b-4c82-d3e6-5f7a9b2c4d61",
  "facility_id": "c9e2a3b5-7d1f-4c68-b4a7-2e8f6d9c1b35",
  "key_prefix": "xk_h3yd",
  "name": "Underwater Hydrophone #3",
  "scopes": ["upload", "ingest"],
  "expires_at": "2026-06-01T00:00:00Z",
  "created_at": "2025-06-15T10:00:00Z",
  // IMPORTANT: This is the only time the full key is returned. Copy it now.
  "key": "xk_h3yd8k2m5p7r9t1v3x5z7b9d1f3h5j7l9n1q3s5u7w9y1a3c5e7g9i1k3m5o7q9"
}

curl:

# Create an API key for the Busan hydrophone and save the key to a file
curl -X POST https://api.xylolabs.com/api/v1/api-keys \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Underwater Hydrophone #3",
    "scopes": ["upload", "ingest"],
    "expires_at": "2026-06-01T00:00:00Z"
  }' | tee api_key_response.json | jq -r '.key'

DELETE /api/v1/api-keys/{id}

Revokes an API key. Any device still using it will start getting 401 errors on its next request.

Auth: JWT Bearer Token Role: facility_admin or higher

Path Parameters:

Parameter Type Description
id UUID API Key ID

Response 204 No Content

Error Cases:

Status Condition
403 Forbidden Insufficient role or cross-facility access

curl:

# Revoke a compromised API key
curl -X DELETE https://api.xylolabs.com/api/v1/api-keys/3c1d5e7f-9a4b-4c82-d3e6-5f7a9b2c4d61 \
  -H "Authorization: Bearer $TOKEN"

6. Devices

Devices represent physical hardware (RPi Pico 2, ESP32, nRF9160, etc.) within a facility. Each device has a facility-scoped device_uid (integer). You can register devices manually through the dashboard, or they'll be auto-created when an ingest session references a new device_uid.

GET /api/v1/devices

Lists all devices in the current user's facility.

Auth: JWT Bearer Token Role: Any authenticated user

Response 200 OK:

[
  {
    "id": "4d2e6f8a-1b3c-4d93-e4f7-6a8b1c3d5e72",
    "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
    "device_uid": 1,
    "name": "Turbine Hall Sensor Array",
    "hardware_version": "rp2350",
    "firmware_version": "2.1.0",
    "is_active": true,
    "last_seen_at": "2025-06-15T10:30:00Z",
    "created_at": "2025-06-01T09:00:00Z"
  },
  {
    "id": "5e3f7a9b-2c4d-4e14-f5a8-7b9c2d4e6f83",
    "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
    "device_uid": 2,
    "name": "Rooftop Wind Noise Monitor",
    "hardware_version": "nrf9160-dk",
    "firmware_version": "1.0.3",
    "is_active": true,
    "last_seen_at": "2025-06-15T10:28:00Z",
    "created_at": "2025-05-15T11:00:00Z"
  }
]

curl:

curl https://api.xylolabs.com/api/v1/devices \
  -H "Authorization: Bearer $TOKEN"

POST /api/v1/devices

Registers a new device. The device_uid must be unique within the facility.

Auth: JWT Bearer Token Role: facility_admin or higher

Request Body:

{
  "device_uid": 3,
  "name": "Bird Call Recorder",
  "hardware_version": "esp32-s3",
  "firmware_version": "1.4.2"
}
Field Type Required Description
device_uid u32 Yes Unique device number within facility
name string Yes Device display name
hardware_version string No Hardware version identifier
firmware_version string No Firmware version identifier

Response 201 Created:

{
  "id": "6f4a8b1c-3d5e-4f25-a6b9-8c1d3e5f7a94",
  "facility_id": "d5f8c2e1-3a9b-4d72-8c6e-4b1a7f9d3e56",
  "device_uid": 3,
  "name": "Bird Call Recorder",
  "hardware_version": "esp32-s3",
  "firmware_version": "1.4.2",
  "is_active": true,
  "last_seen_at": null,       // hasn't sent any data yet
  "created_at": "2025-06-15T10:00:00Z"
}

Error Cases:

Status Condition
403 Forbidden Insufficient role
409 Conflict Device with same device_uid already exists in this facility

curl:

# Register the new sensor device at the facility
curl -X POST https://api.xylolabs.com/api/v1/devices \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "device_uid": 3,
    "name": "Bird Call Recorder",
    "hardware_version": "esp32-s3",
    "firmware_version": "1.4.2"
  }'

GET /api/v1/devices/{id}

Fetches a single device by ID.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Device ID

Response 200 OK: Device object.

Error Cases:

Status Condition
403 Forbidden Cross-facility access
404 Not Found Device not found

curl:

curl https://api.xylolabs.com/api/v1/devices/4d2e6f8a-1b3c-4d93-e4f7-6a8b1c3d5e72 \
  -H "Authorization: Bearer $TOKEN"

PATCH /api/v1/devices/{id}

Updates a device. Only include the fields you want to change.

Auth: JWT Bearer Token Role: facility_admin or higher

Path Parameters:

Parameter Type Description
id UUID Device ID

Request Body:

{
  "firmware_version": "2.2.0",
  "name": "Turbine Hall Sensor Array (Updated)"
}
Field Type Required Description
name string No New device name
hardware_version string No New hardware version
firmware_version string No New firmware version
is_active boolean No Enable/disable device

Response 200 OK: Updated device object.

Error Cases:

Status Condition
403 Forbidden Insufficient role or cross-facility access
404 Not Found Device not found

curl:

# Update the firmware version after an OTA update
curl -X PATCH https://api.xylolabs.com/api/v1/devices/4d2e6f8a-1b3c-4d93-e4f7-6a8b1c3d5e72 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"firmware_version":"2.2.0"}'

7. Audio Upload

POST /api/v1/uploads

Upload an audio file as multipart form data. The original file is stored in S3 as-is, and a transcode job is automatically created to produce a web-playable copy (Opus/WebM by default).

Auth: API Key (X-Api-Key header) Role: N/A (API Key scoped to facility)

Content-Type: multipart/form-data

Multipart Fields:

Field Type Required Description
file file Yes Audio file (WAV, FLAC, MP3, etc.)
metadata text (JSON) No Arbitrary metadata JSON
tags text (JSON) No Array of tag UUIDs

Response 200 OK:

{
  "id": "7a5b9c2d-4e6f-4a36-b7c1-9d2e4f6a8b15",
  "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
  "api_key_id": "1a8b3c5d-7e2f-4a69-b1c4-3d5e7f9a2b48",
  "uploaded_by": null,
  "original_filename": "turbine_hall_2025-06-15_14h30.wav",
  "original_format": "wav",
  "original_size_bytes": 115200000,
  "duration_ms": null,          // populated after transcoding finishes
  "sample_rate_hz": null,
  "channels": null,
  "bit_depth": null,
  "codec": null,
  "metadata": {
    "source": "turbine-array",
    "location": "Hall B, Section 3",
    "sample_rate": 384000,
    "bit_depth": 24
  },
  "status": "pending",         // will change to "processing" then "completed"
  "tags": [],
  "created_at": "2025-06-15T14:30:00Z",
  "updated_at": "2025-06-15T14:30:00Z"
}

Upload statuses: pending, processing, completed, failed.

If you're uploading over Cat-M1, keep in mind that a 100 MB WAV file takes roughly 45 minutes at the ~37 KB/s Cat-M1 uplink rate. Consider compressing to FLAC first (typically 50-60% of the original size) or scheduling uploads during off-peak hours. See Section 22 for more Cat-M1 tips.

curl:

# Upload a high-resolution turbine hall recording with metadata
curl -X POST https://api.xylolabs.com/api/v1/uploads \
  -H "X-Api-Key: $API_KEY" \
  -F "file=@turbine_hall_2025-06-15_14h30.wav" \
  -F 'metadata={"source":"turbine-array","location":"Hall B, Section 3","sample_rate":384000,"bit_depth":24}' \
  -F 'tags=["a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"]'

Python example:

import requests

api_key = "xk_t4r78k2m5p7r9t1v3x5z7b9d1f3h5j7l9n1q3s5u7w9y1a3c5e7g9i1k3m5o7q9"
url = "https://api.xylolabs.com/api/v1/uploads"

with open("turbine_hall_2025-06-15_14h30.wav", "rb") as f:
    response = requests.post(
        url,
        headers={"X-Api-Key": api_key},
        files={"file": ("turbine_hall_2025-06-15_14h30.wav", f, "audio/wav")},
        data={
            "metadata": '{"source":"turbine-array","location":"Hall B, Section 3"}',
            "tags": '["a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"]',
        },
    )

print(response.json())
# {'id': '7a5b9c2d-...', 'status': 'pending', ...}

Error Cases:

Status Condition
400 Bad Request Missing file field or invalid multipart
401 Unauthorized Invalid or missing API key

Storage Flow:

Device --> HTTP POST --> Server --> S3 (original preserved)
                                     |
                              Auto-transcode
                                     |
                              S3 (web-playable copy)

GET /api/v1/uploads

Lists uploads for the current user's facility, with pagination.

Auth: JWT Bearer Token Role: Any authenticated user

Query Parameters:

Parameter Type Default Description
page integer 1 Page number (1-based)
per_page integer 20 Items per page (1-100)
status string -- Filter by status: pending, processing, completed, failed

Response 200 OK:

{
  "items": [
    {
      "id": "7a5b9c2d-4e6f-4a36-b7c1-9d2e4f6a8b15",
      "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
      "original_filename": "turbine_hall_2025-06-15_14h30.wav",
      "original_format": "wav",
      "original_size_bytes": 115200000,
      "status": "completed",
      "tags": [
        {
          "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
          "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
          "name": "Turbine Monitoring",
          "color": "#FF5722",
          "created_at": "2025-06-01T09:00:00Z"
        }
      ],
      "created_at": "2025-06-15T14:30:00Z",
      "updated_at": "2025-06-15T14:35:00Z"
    }
  ],
  "total": 42,
  "page": 1,
  "per_page": 20
}

curl:

# Get the first page of completed uploads
curl "https://api.xylolabs.com/api/v1/uploads?page=1&per_page=10&status=completed" \
  -H "Authorization: Bearer $TOKEN"

GET /api/v1/uploads/{id}

Fetches a single upload by ID.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Upload ID

Response 200 OK: Full upload object (same schema as list item).

Error Cases:

Status Condition
403 Forbidden Cross-facility access
404 Not Found Upload not found

curl:

curl https://api.xylolabs.com/api/v1/uploads/7a5b9c2d-4e6f-4a36-b7c1-9d2e4f6a8b15 \
  -H "Authorization: Bearer $TOKEN"

PATCH /api/v1/uploads/{id}

Updates upload metadata and/or tags. Metadata is a full replacement, not a merge -- include all the fields you want to keep.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Upload ID

Request Body:

{
  "metadata": {
    "source": "turbine-array",
    "location": "Hall B, Section 3",
    "notes": "Anomalous low-frequency hum detected at ~120 Hz"
  },
  "tags": ["a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"]
}
Field Type Required Description
metadata object No Replace metadata (full replace)
tags UUID[] No Replace tags (removes existing, adds new)

Response 200 OK: Updated upload object.

Error Cases:

Status Condition
403 Forbidden Cross-facility access
404 Not Found Upload not found

curl:

# Add a note about an anomaly found during review
curl -X PATCH https://api.xylolabs.com/api/v1/uploads/7a5b9c2d-4e6f-4a36-b7c1-9d2e4f6a8b15 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"metadata":{"source":"turbine-array","notes":"Anomalous 120 Hz hum"}}'

DELETE /api/v1/uploads/{id}

Deletes an upload. This removes the file from S3 and cascade-deletes all associated transcode jobs and tag associations.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Upload ID

Response 204 No Content

Error Cases:

Status Condition
403 Forbidden Cross-facility access
404 Not Found Upload not found

curl:

# Permanently delete an upload and its transcoded copies
curl -X DELETE https://api.xylolabs.com/api/v1/uploads/7a5b9c2d-4e6f-4a36-b7c1-9d2e4f6a8b15 \
  -H "Authorization: Bearer $TOKEN"

POST /api/v1/uploads/{id}/retranscode

Kicks off a new transcode job for an existing upload using the current default transcode settings. Useful after changing the default format in system configuration.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Upload ID

Response 200 OK:

{
  "job_id": "8b6c1d3e-5f7a-4b47-c8d2-1e3f5a7b9c26"
}

Error Cases:

Status Condition
403 Forbidden Cross-facility access
404 Not Found Upload not found

curl:

# Re-transcode after switching the default format from Opus to AAC
curl -X POST https://api.xylolabs.com/api/v1/uploads/7a5b9c2d-4e6f-4a36-b7c1-9d2e4f6a8b15/retranscode \
  -H "Authorization: Bearer $TOKEN"

8. Audio Streaming

GET /api/v1/streams/{id}

Streams a transcoded audio file by proxying the latest completed transcode result through the backend. This avoids exposing internal or browser-unreachable S3 endpoints.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Upload ID

Response 200 OK:

Headers:

Content-Type: audio/webm
Cache-Control: private, max-age=3600

Error Cases:

Status Condition
403 Forbidden Cross-facility access
404 Not Found Upload not found or no completed transcode job

curl:

# Download the transcoded audio (curl follows the redirect with -L)
curl -L https://api.xylolabs.com/api/v1/streams/7a5b9c2d-4e6f-4a36-b7c1-9d2e4f6a8b15 \
  -H "Authorization: Bearer $TOKEN" \
  --output turbine_hall_transcoded.webm

9. Transcode Jobs

GET /api/v1/transcode-jobs

Lists transcode jobs for the current user's facility, with pagination.

Auth: JWT Bearer Token Role: user or higher

Query Parameters:

Parameter Type Default Description
page integer 1 Page number
per_page integer 20 Items per page (1-100)
status string -- Filter: queued, running, completed, failed, cancelled

Response 200 OK:

{
  "items": [
    {
      "id": "8b6c1d3e-5f7a-4b47-c8d2-1e3f5a7b9c26",
      "upload_id": "7a5b9c2d-4e6f-4a36-b7c1-9d2e4f6a8b15",
      "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
      "target_format": "opus_webm",
      "target_codec": "libopus",
      "target_container": "webm",
      "target_bitrate": "128k",
      "target_sample_rate": null,
      "output_size_bytes": 2048000,
      "output_duration_ms": 120000,
      "status": "completed",
      "error_message": null,
      "attempts": 1,
      "max_attempts": 3,
      "queued_at": "2025-06-15T14:30:00Z",
      "started_at": "2025-06-15T14:30:05Z",
      "completed_at": "2025-06-15T14:30:15Z",
      "worker_id": "worker-01"
    }
  ],
  "total": 10,
  "page": 1,
  "per_page": 20
}

curl:

# Check which transcode jobs are still running
curl "https://api.xylolabs.com/api/v1/transcode-jobs?status=running" \
  -H "Authorization: Bearer $TOKEN"

GET /api/v1/transcode-jobs/{id}

Fetches a single transcode job by ID.

Auth: JWT Bearer Token Role: user or higher

Path Parameters:

Parameter Type Description
id UUID Transcode Job ID

Response 200 OK: Full transcode job object (same schema as list item).

Error Cases:

Status Condition
403 Forbidden Cross-facility access
404 Not Found Job not found

curl:

curl https://api.xylolabs.com/api/v1/transcode-jobs/8b6c1d3e-5f7a-4b47-c8d2-1e3f5a7b9c26 \
  -H "Authorization: Bearer $TOKEN"

POST /api/v1/transcode-jobs/{id}/cancel

Cancels a queued or running transcode job.

Auth: JWT Bearer Token Role: facility_admin or higher

Path Parameters:

Parameter Type Description
id UUID Transcode Job ID

Response 204 No Content

Error Cases:

Status Condition
403 Forbidden Insufficient role or cross-facility access
404 Not Found Job not found

curl:

# Cancel a stuck transcode job
curl -X POST https://api.xylolabs.com/api/v1/transcode-jobs/8b6c1d3e-5f7a-4b47-c8d2-1e3f5a7b9c26/cancel \
  -H "Authorization: Bearer $TOKEN"

10. Tags

Tags are labels you can attach to uploads for organization and filtering. They're scoped to a facility, so different facilities can have tags with the same name without colliding.

GET /api/v1/tags

Lists all tags for the current user's facility.

Auth: JWT Bearer Token Role: user or higher

Response 200 OK:

[
  {
    "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
    "name": "Turbine Monitoring",
    "color": "#FF5722",
    "created_at": "2025-06-01T09:00:00Z"
  },
  {
    "id": "b2c3d4e5-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
    "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
    "name": "Night Recording",
    "color": "#3F51B5",
    "created_at": "2025-06-03T14:00:00Z"
  }
]

curl:

curl https://api.xylolabs.com/api/v1/tags \
  -H "Authorization: Bearer $TOKEN"

POST /api/v1/tags

Creates a new tag.

Auth: JWT Bearer Token Role: facility_admin or higher

Request Body:

{
  "name": "Anomaly Detected",
  "color": "#F44336"
}
Field Type Required Description
name string Yes Tag name
color string No Hex color for display (e.g. #F44336)

Response 200 OK: Created tag object.

curl:

# Create a red tag for flagging anomalies
curl -X POST https://api.xylolabs.com/api/v1/tags \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Anomaly Detected","color":"#F44336"}'

DELETE /api/v1/tags/{id}

Deletes a tag. Any uploads that had this tag will have it removed.

Auth: JWT Bearer Token Role: facility_admin or higher

Path Parameters:

Parameter Type Description
id UUID Tag ID

Response 204 No Content

curl:

curl -X DELETE https://api.xylolabs.com/api/v1/tags/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d \
  -H "Authorization: Bearer $TOKEN"

11. Metadata Ingest

The Metadata Ingest subsystem lets embedded devices stream sensor data to the server in real-time using the XMBP (Xylolabs Metadata Binary Protocol). This section covers the device-facing ingest API.

Developer Tips

Session lifecycle: A session represents one continuous recording period from a device. Create it when the device powers on or starts recording, send data during operation, and close it when you're done. If the device reboots unexpectedly, the server will eventually mark the session as error after a timeout.

HTTP POST vs WebSocket: For Cat-M1 devices and intermittent senders, use HTTP POST. WebSocket is better for WiFi-connected devices with continuous high-frequency streams. Cat-M1 modems have limited socket support, and HTTP POST with keep-alive works more reliably with cellular networks.

Batch sizing: Don't send one sample at a time -- batch them up. For a 100 Hz stream, 250 samples per batch (one POST every 2.5 seconds) is a good starting point. See the bandwidth calculations in Section 16 and Section 22 for Cat-M1 constraints.

Ingest Flow

1. Create Session (POST /ingest/sessions)
   --> Session ID + stream definitions returned

2. Send Data (repeat)
   |-- HTTP: POST /ingest/sessions/{id}/data (XMBP binary body)
   |-- WebSocket: /ingest/ws (XMBP binary frames)

3. Close Session (POST /ingest/sessions/{id}/close)

POST /api/v1/ingest/sessions

Creates a new ingest session with stream definitions. If the device_uid doesn't match an existing device, one is automatically created.

Auth: API Key (X-Api-Key header) Role: N/A (API Key scoped to facility)

Request Body:

{
  "device_uid": 1,
  "name": "Turbine Hall Continuous Monitoring",
  "streams": [
    {
      "stream_index": 0,
      "name": "vibration_x",
      "value_type": "f32",
      "unit": "g",
      "sample_rate_hz": 500.0,
      "description": "X-axis accelerometer on turbine bearing"
    },
    {
      "stream_index": 1,
      "name": "vibration_y",
      "value_type": "f32",
      "unit": "g",
      "sample_rate_hz": 500.0,
      "description": "Y-axis accelerometer on turbine bearing"
    },
    {
      "stream_index": 2,
      "name": "temperature",
      "value_type": "f32",
      "unit": "celsius",
      "sample_rate_hz": 10.0,
      "description": "Bearing temperature (slower rate, less critical)"
    },
    {
      "stream_index": 3,
      "name": "rpm",
      "value_type": "f32",
      "unit": "rpm",
      "sample_rate_hz": 10.0,
      "description": "Turbine shaft RPM from tachometer"
    }
  ],
  "metadata": {
    "location": "Hall B, Turbine #7",
    "firmware": "2.1.0",
    "calibration_date": "2025-06-01"
  }
}

CreateIngestSessionRequest fields:

Field Type Required Description
device_uid u32 Yes Facility-scoped unique device number
name string No Session name
streams StreamDefinition[] Yes Stream definitions
metadata object No Session metadata

StreamDefinition fields:

Field Type Required Description
stream_index u16 Yes Stream index (matches XMBP stream_id)
name string Yes Stream name
value_type string Yes Value type (f32, f64, i32, i64, bool, string, bytes, f64_array, f32_array, i32_array, json, i16, i8)
unit string No Unit label (display only)
sample_rate_hz f32 No Sampling rate (display only)
description string No Description

Response 201 Created:

{
  "id": "9c7d2e4f-6a8b-4c58-d9e3-2f4a6b8c1d37",
  "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
  "device_id": "4d2e6f8a-1b3c-4d93-e4f7-6a8b1c3d5e72",
  "status": "active",
  "started_at": "2025-06-15T10:00:00Z",
  "total_samples": 0,
  "total_bytes": 0,
  "missed_batches": 0,
  "streams": [
    {
      "id": "1e4f7a2b-5c8d-4e69-a1b4-7c2d5e8f1a3b",
      "stream_index": 0,
      "name": "vibration_x",
      "value_type": "f32",
      "unit": "g",
      "sample_rate_hz": 500.0
    },
    {
      "id": "2f5a8b3c-6d9e-4f71-b2c5-8d3e6f9a2b4c",
      "stream_index": 1,
      "name": "vibration_y",
      "value_type": "f32",
      "unit": "g",
      "sample_rate_hz": 500.0
    }
    // ... remaining streams
  ]
}

curl:

# Start a monitoring session with 4 sensor channels
curl -X POST https://api.xylolabs.com/api/v1/ingest/sessions \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "device_uid": 1,
    "name": "Turbine Hall Continuous Monitoring",
    "streams": [
      {"stream_index":0,"name":"vibration_x","value_type":"f32","unit":"g","sample_rate_hz":500},
      {"stream_index":1,"name":"vibration_y","value_type":"f32","unit":"g","sample_rate_hz":500},
      {"stream_index":2,"name":"temperature","value_type":"f32","unit":"celsius","sample_rate_hz":10},
      {"stream_index":3,"name":"rpm","value_type":"f32","unit":"rpm","sample_rate_hz":10}
    ],
    "metadata": {"location":"Hall B, Turbine #7","firmware":"2.1.0"}
  }'

POST /api/v1/ingest/sessions/{id}/data

Sends an XMBP binary batch via HTTP POST.

Auth: API Key (X-Api-Key header) Role: N/A (API Key scoped to facility)

Content-Type: application/octet-stream

Path Parameters:

Parameter Type Description
id UUID Session ID

Request Body: Raw XMBP binary batch (see XMBP Protocol Reference).

Response 200 OK:

{
  "accepted_samples": 1000,    // running total of samples received in this session
  "missed_batches": 0          // non-zero if the server detected gaps in batch_seq
}
Field Type Description
accepted_samples u32 Total samples received so far
missed_batches u32 Number of missed batches (based on batch sequence numbers)

Error Cases:

Status Condition
400 Bad Request XMBP decode error
401 Unauthorized Invalid API key
404 Not Found Session not found or facility mismatch

curl:

# Send a pre-built XMBP binary batch
curl -X POST https://api.xylolabs.com/api/v1/ingest/sessions/9c7d2e4f-6a8b-4c58-d9e3-2f4a6b8c1d37/data \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @batch.bin

POST /api/v1/ingest/sessions/{id}/close

Closes an ingest session. The server flushes any buffered data and changes the session status to closed.

Auth: API Key (X-Api-Key header) Role: N/A (API Key scoped to facility)

Path Parameters:

Parameter Type Description
id UUID Session ID

Response 200 OK: Final session state with totals.

{
  "id": "9c7d2e4f-6a8b-4c58-d9e3-2f4a6b8c1d37",
  "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
  "device_id": "4d2e6f8a-1b3c-4d93-e4f7-6a8b1c3d5e72",
  "status": "closed",
  "started_at": "2025-06-15T10:00:00Z",
  "total_samples": 1200000,    // ~40 minutes of 4-channel data at 500 Hz
  "total_bytes": 9600000,      // ~9.6 MB transferred
  "missed_batches": 2,         // 2 batches lost (cellular dropout, perhaps)
  "streams": [...]
}

Error Cases:

Status Condition
403 Forbidden Session belongs to another facility
404 Not Found Session not found

curl:

# Close the session when the device is shutting down
curl -X POST https://api.xylolabs.com/api/v1/ingest/sessions/9c7d2e4f-6a8b-4c58-d9e3-2f4a6b8c1d37/close \
  -H "X-Api-Key: $API_KEY"

GET /api/v1/ingest/ws

WebSocket upgrade endpoint for real-time binary data streaming. Best for WiFi-connected devices with continuous high-frequency data. For Cat-M1 devices, prefer HTTP POST.

Auth: X-Api-Key header required Role: N/A

Query Parameters:

Parameter Type Required Description
session_id UUID Yes Target session ID

The WebSocket endpoint rejects API keys passed via query parameters because URLs are commonly logged by proxies, CDNs, and browsers.

Connection:

GET /api/v1/ingest/ws?session_id=9c7d2e4f-...
Connection: Upgrade
Upgrade: websocket
X-Api-Key: xk_t4r7...

Protocol:

Direction Frame Type Content
Client --> Server Binary XMBP batch
Server --> Client Binary ACK: 4-byte u32 big-endian (accepted_samples)
Server --> Client Text Error message (connection closed after)

Behavior: - Idle Timeout: The server closes the connection after 60 seconds of inactivity. - Ping/Pong: The server automatically responds to Ping frames with Pong. - Error: On XMBP decode failure, the server sends a Text frame with the error and may close the connection. - Max frame size: 64 KB - Max message size: 256 KB

ACK Processing (C pseudocode):

uint8_t ack_buf[4];
ws_recv(ack_buf, 4);
uint32_t accepted = (ack_buf[0] << 24) | (ack_buf[1] << 16)
                  | (ack_buf[2] << 8)  | ack_buf[3];
// 'accepted' is the running total of samples the server has stored

WebSocket vs HTTP Comparison:

Aspect HTTP POST WebSocket
Connection overhead New connection per batch (or keep-alive) Single long-lived connection
Header overhead ~200 bytes per request 2-14 bytes per frame
ACK format JSON response 4-byte binary
Best for Intermittent sends, Cat-M1 Continuous streaming, WiFi
Reconnection Not needed Must implement auto-reconnect

12. Metadata Query

These endpoints let you query previously ingested sensor data. All require JWT authentication.

GET /api/v1/metadata/sessions

Lists ingest sessions for the current user's facility.

Auth: JWT Bearer Token Role: Any authenticated user

Query Parameters:

Parameter Type Default Description
page integer 1 Page number
per_page integer 20 Items per page (1-100)
status string -- Filter: active, closing, closed, error
device_id UUID -- Filter by device

Response 200 OK:

{
  "items": [
    {
      "id": "9c7d2e4f-6a8b-4c58-d9e3-2f4a6b8c1d37",
      "facility_id": "b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14",
      "device_id": "4d2e6f8a-1b3c-4d93-e4f7-6a8b1c3d5e72",
      "status": "closed",
      "started_at": "2025-06-15T10:00:00Z",
      "total_samples": 1200000,
      "total_bytes": 9600000,
      "missed_batches": 2,
      "streams": [
        {
          "id": "1e4f7a2b-5c8d-4e69-a1b4-7c2d5e8f1a3b",
          "stream_index": 0,
          "name": "vibration_x",
          "value_type": "f32",
          "unit": "g",
          "sample_rate_hz": 500.0
        }
      ]
    }
  ],
  "total": 5,
  "page": 1,
  "per_page": 20
}

curl:

# List all closed sessions for a specific device
curl "https://api.xylolabs.com/api/v1/metadata/sessions?status=closed&device_id=4d2e6f8a-1b3c-4d93-e4f7-6a8b1c3d5e72" \
  -H "Authorization: Bearer $TOKEN"

GET /api/v1/metadata/sessions/{id}

Fetches a single ingest session with its stream definitions.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Session ID

Response 200 OK: Session object (same schema as list item).

Error Cases:

Status Condition
403 Forbidden Cross-facility access
404 Not Found Session not found

curl:

curl https://api.xylolabs.com/api/v1/metadata/sessions/9c7d2e4f-6a8b-4c58-d9e3-2f4a6b8c1d37 \
  -H "Authorization: Bearer $TOKEN"

GET /api/v1/metadata/sessions/{id}/streams/{stream_id}/data

Queries data samples for a specific stream. You can filter by time range and request server-side downsampling when fetching large datasets for visualization.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Session ID
stream_id UUID Stream ID

Query Parameters:

Parameter Type Default Description
start_us i64 0 Start timestamp in microseconds
end_us i64 MAX End timestamp in microseconds
downsample u32 -- Target number of samples (server-side downsampling)

Response 200 OK:

{
  "stream_id": "1e4f7a2b-5c8d-4e69-a1b4-7c2d5e8f1a3b",
  "stream_name": "vibration_x",
  "value_type": "f32",
  "sample_count": 1000,
  "timestamps_us": [1718442000000000, 1718442002000, 1718442004000],
  "values": [0.042, 0.051, 0.038]
}

curl:

# Get 1000 downsampled vibration readings for a chart
curl "https://api.xylolabs.com/api/v1/metadata/sessions/9c7d2e4f-.../streams/1e4f7a2b-.../data?start_us=0&downsample=1000" \
  -H "Authorization: Bearer $TOKEN"

GET /api/v1/metadata/sessions/{id}/export

Exports all session data as a CSV or JSON file download.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Session ID

Query Parameters:

Parameter Type Default Description
format string csv Export format: csv or json

Response 200 OK:

For CSV:

Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="session_9c7d2e4f-....csv"

timestamp_us,vibration_x,vibration_y,temperature,rpm
1718442000000000,0.042,0.039,72.3,1485
1718442002000,0.051,0.044,72.3,1486

For JSON:

{
  "session_id": "9c7d2e4f-6a8b-4c58-d9e3-2f4a6b8c1d37",
  "streams": [
    {
      "stream_id": "1e4f7a2b-5c8d-4e69-a1b4-7c2d5e8f1a3b",
      "stream_name": "vibration_x",
      "value_type": "f32",
      "sample_count": 600000,
      "timestamps_us": [1718442000000000, 1718442002000],
      "values": [0.042, 0.051]
    }
  ]
}

Error Cases:

Status Condition
400 Bad Request Unsupported export format
403 Forbidden Cross-facility access
404 Not Found Session not found

curl:

# Download a full session as CSV for analysis in Python/R/MATLAB
curl "https://api.xylolabs.com/api/v1/metadata/sessions/9c7d2e4f-.../export?format=csv" \
  -H "Authorization: Bearer $TOKEN" \
  --output turbine7_session_2025-06-15.csv

GET /api/v1/metadata/sessions/{id}/live

Subscribe to live sensor data from an active ingest session via Server-Sent Events (SSE). This is how the dashboard shows real-time charts.

Auth: JWT Bearer Token Role: Any authenticated user

Path Parameters:

Parameter Type Description
id UUID Session ID (must be active)

Response: SSE stream

event: sample
data: {"stream_index":0,"timestamp_us":1718442000000000,"value":0.042}

event: sample
data: {"stream_index":1,"timestamp_us":1718442000000000,"value":0.039}

event: lag
data: {"skipped":10}

Events:

Event Description
sample A new data sample arrived from the device
lag Your client is falling behind; some events were skipped

curl:

# Watch live vibration data (Ctrl+C to stop)
curl -N "https://api.xylolabs.com/api/v1/metadata/sessions/9c7d2e4f-.../live" \
  -H "Authorization: Bearer $TOKEN"

13. System Configuration

Runtime system settings managed by Super Admins. Values are stored in the database and cached in memory, so changes take effect immediately without a restart.

GET /api/v1/config

Returns all configuration entries grouped by category.

Auth: JWT Bearer Token Role: super_admin

Response 200 OK:

{
  "categories": [
    {
      "category": "transcode",
      "entries": [
        {
          "key": "transcode.default_format",
          "value": "opus_webm",
          "category": "transcode",
          "value_type": "string",
          "description": "Default transcode output format",
          "updated_at": "2025-06-15T10:00:00Z"
        },
        {
          "key": "transcode.default_bitrate",
          "value": "128k",
          "category": "transcode",
          "value_type": "string",
          "description": "Default transcode bitrate",
          "updated_at": "2025-06-15T10:00:00Z"
        }
      ]
    }
  ]
}

curl:

# View all system configuration (super_admin only)
curl https://api.xylolabs.com/api/v1/config \
  -H "Authorization: Bearer $TOKEN"

GET /api/v1/config/{key}

Fetches a single configuration entry.

Auth: JWT Bearer Token Role: super_admin

Path Parameters:

Parameter Type Description
key string Configuration key (e.g. transcode.default_format)

Response 200 OK:

{
  "key": "transcode.default_format",
  "value": "opus_webm",
  "category": "transcode",
  "value_type": "string",
  "description": "Default transcode output format",
  "updated_at": "2025-06-15T10:00:00Z"
}

Error Cases:

Status Condition
403 Forbidden User is not a Super Admin
404 Not Found Config key not found

curl:

curl https://api.xylolabs.com/api/v1/config/transcode.default_format \
  -H "Authorization: Bearer $TOKEN"

PUT /api/v1/config/{key}

Updates a configuration value.

Auth: JWT Bearer Token Role: super_admin

Path Parameters:

Parameter Type Description
key string Configuration key

Request Body:

{
  "value": "aac_mp4"
}
Field Type Required Description
value any (JSON) Yes New value (type must match value_type of the config entry)

Response 200 OK: Updated config entry object.

curl:

# Switch default transcode format from Opus/WebM to AAC/MP4
curl -X PUT https://api.xylolabs.com/api/v1/config/transcode.default_format \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value":"aac_mp4"}'

POST /api/v1/config/reset/{key}

Resets a configuration value to its default.

Auth: JWT Bearer Token Role: super_admin

Path Parameters:

Parameter Type Description
key string Configuration key

Response 200 OK: Reset config entry object with default value.

curl:

# Oops, revert the format change back to the default
curl -X POST https://api.xylolabs.com/api/v1/config/reset/transcode.default_format \
  -H "Authorization: Bearer $TOKEN"

14. Dashboard Stats

GET /api/v1/stats/overview

Returns a summary of system statistics for the dashboard home page.

Auth: JWT Bearer Token Role: Any authenticated user

Response 200 OK:

For Facility Admin / User (scoped to their facility):

{
  "users": 15,
  "uploads": 342
}

For Super Admin (system-wide totals, including facility count):

{
  "users": 47,
  "uploads": 1284,
  "facilities": 5
}

curl:

curl https://api.xylolabs.com/api/v1/stats/overview \
  -H "Authorization: Bearer $TOKEN"

15. Health

GET /api/health

Basic liveness check. Returns 200 OK if the server process is running. This endpoint doesn't verify database or storage connectivity.

Auth: None Role: None

Response 200 OK:

{
  "status": "ok"
}

curl:

# Quick liveness probe (good for container health checks)
curl https://api.xylolabs.com/api/health

GET /api/health/ready

Readiness check that verifies both database and S3 connectivity. Use this for load balancer health checks so traffic doesn't get routed to an instance that can't actually serve requests.

Auth: None Role: None

Response 200 OK:

{
  "status": "ready",
  "postgres": "ok",
  "s3": "ok"
}

If S3 is unreachable (the server is still running but uploads will fail):

{
  "status": "ready",
  "postgres": "ok",
  "s3": "error"
}

curl:

# Full readiness probe (checks database + S3)
curl https://api.xylolabs.com/api/health/ready

GET/POST /api/v1/ping

API key validation and connectivity test. Verifies the X-Api-Key header is valid and returns the key name and associated facility. Accepts an optional message field that gets echoed back.

Use this from MCU firmware to confirm the API key works before starting an ingest session.

Auth: API Key (X-Api-Key header)

Request body (optional, POST only):

{
  "message": "hello from pico"
}

Response 200 OK:

{
  "pong": true,
  "message": "hello from pico",
  "api_key_name": "production-sensor-01",
  "facility_id": "b666b8c3-bbc2-4675-a3ac-8b6a1e6dad9f"
}

If no body is sent, message defaults to "pong".

Errors: - 401 — missing or invalid API key

curl:

# GET — quick key validation
curl -H "X-Api-Key: xk_your_key_here" https://api.xylolabs.com/api/v1/ping

# POST — with echo message
curl -X POST -H "X-Api-Key: xk_your_key_here" \
     -H "Content-Type: application/json" \
     -d '{"message":"test"}' \
     https://api.xylolabs.com/api/v1/ping

16. XMBP Protocol Reference

XMBP (Xylolabs Metadata Binary Protocol) is a compact binary protocol for transmitting sensor data over constrained networks like LTE Cat-M1. All multi-byte numbers are big-endian.

Batch Envelope

Top-level structure contained in each HTTP POST body or WebSocket binary frame.

+--------+------+--------------------------------------+
| Offset | Size | Field                                |
+--------+------+--------------------------------------+
|      0 |    4 | magic        (0x584D4250 = "XMBP")   |
|      4 |    1 | version      (1)                     |
|      5 |    1 | flags                                |
|      6 |    2 | batch_seq    (u16)                   |
|      8 |    4 | device_id    (u32, conditional)       |
|   8/12 |    2 | stream_count (u16)                   |
|  10/14 |  ... | StreamBlock[stream_count]            |
+--------+------+--------------------------------------+

Flags:

Bit Name Description
0 (0x01) HAS_DEVICE_ID When set, device_id field (4 bytes) is present at offset 8
1 (0x02) ZSTD_COMPRESSED Reserved for future zstd compression
2-7 -- Reserved

When HAS_DEVICE_ID is set, stream_count starts at offset 12; otherwise at offset 8.

StreamBlock

Data block for a single stream (channel).

+--------+------+--------------------------------------+
| Offset | Size | Field                                |
+--------+------+--------------------------------------+
|      0 |    2 | stream_id     (u16)                  |
|      2 |    1 | value_type    (u8)                   |
|      3 |    2 | sample_count  (u16)                  |
|      5 |  ... | Sample[sample_count]                 |
+--------+------+--------------------------------------+

Sample Format

Fixed-size types (f32, f64, i32, i64, bool):

+--------+------+--------------------------------------+
| Offset | Size | Field                                |
+--------+------+--------------------------------------+
|      0 |    8 | timestamp_us  (u64, microseconds)    |
|      8 |    N | value         (type-dependent size)  |
+--------+------+--------------------------------------+

Variable-size types (string, bytes, arrays, json):

+--------+------+--------------------------------------+
| Offset | Size | Field                                |
+--------+------+--------------------------------------+
|      0 |    8 | timestamp_us  (u64, microseconds)    |
|      8 |    2 | length        (u16, bytes/elements)  |
|     10 |  ... | data          (variable)             |
+--------+------+--------------------------------------+

Value Type Tags

Tag Type Size Description
0 f64 8 bytes 64-bit IEEE 754 float
1 f32 4 bytes 32-bit IEEE 754 float
2 i64 8 bytes 64-bit signed integer
3 i32 4 bytes 32-bit signed integer
4 bool 1 byte 0 = false, non-zero = true
5 string variable u16 length + UTF-8 bytes
6 bytes variable u16 length + raw bytes
7 f64_array variable u16 element count + f64[]
8 f32_array variable u16 element count + f32[]
9 i32_array variable u16 element count + i32[]
10 json variable u32 length + UTF-8 JSON string
11 i16 2 bytes 16-bit signed integer, big-endian
12 i8 1 byte 8-bit signed integer (stored as u8 cast to i8)

Building an XMBP Batch Step by Step

Here's how to construct a batch containing 2 samples of f32 data on stream 0, without a device_id in the header.

Step 1: Write the header

Bytes:  58 4D 42 50  01  00  00 07  00 01
        ^^^^^^^^^^^  ^^  ^^  ^^^^^  ^^^^^
        magic XMBP   v1  flags=0  seq=7  1 stream
  • flags = 0x00: No HAS_DEVICE_ID, no compression.
  • batch_seq = 7: This is the 8th batch (0-indexed).
  • stream_count = 1: One stream block follows.

Step 2: Write the StreamBlock header

Bytes:  00 00  01  00 02
        ^^^^^  ^^  ^^^^^
        stream_id=0  f32  2 samples

Step 3: Write each sample (timestamp + value)

Sample 1: timestamp = 1718442000000000 us (2025-06-15T10:00:00Z), value = 23.5 C

Bytes:  00 06 1B 7E 7C 4E 80 00   41 BC 00 00
        ^^^^^^^^^^^^^^^^^^^^^^^^   ^^^^^^^^^^^
        timestamp (u64 BE)         23.5 as f32 BE

Sample 2: timestamp = 1718442000002000 us (2 ms later), value = 23.6 C

Bytes:  00 06 1B 7E 7C 4E 87 D0   41 BC CC CD
        ^^^^^^^^^^^^^^^^^^^^^^^^   ^^^^^^^^^^^
        timestamp (u64 BE)         23.6 as f32 BE

Complete batch (34 bytes):

58 4D 42 50 01 00 00 07 00 01        -- header (10 bytes)
00 00 01 00 02                        -- stream block header (5 bytes)
00 06 1B 7E 7C 4E 80 00 41 BC 00 00  -- sample 1: t=..., v=23.5 (12 bytes)
00 06 1B 7E 7C 4E 87 D0 41 BC CC CD  -- sample 2: t=..., v=23.6 (12 bytes)

You can verify this with a hex dump after writing:

xxd batch.bin
# 00000000: 584d 4250 0100 0007 0001 0000 0100 02  XMBP............
# 00000010: 0006 1b7e 7c4e 8000 41bc 0000 0006 1b7e  ...~|N..A......~
# 00000020: 7c4e 87d0 41bc cccd                      |N..A...

Batch Size Calculation

For fixed-size types, you can pre-compute the batch size:

Header size:
  base = 4 (magic) + 1 (version) + 1 (flags) + 2 (batch_seq) + 2 (stream_count) = 10
  with device_id: +4 = 14

StreamBlock size (fixed-size types):
  block_header = 2 (stream_id) + 1 (value_type) + 2 (sample_count) = 5
  sample_size  = 8 (timestamp) + value_size

Total batch size:
  header_size + SUM(5 + sample_count * (8 + value_size)) for each stream

Example: 4 channels of f32, 250 samples/batch, with device_id:

14 + 4 * (5 + 250 * (8 + 4))
= 14 + 4 * (5 + 250 * 12)
= 14 + 4 * 3005
= 14 + 12020
= 12,034 bytes (~12 KB)

Cat-M1 Optimization

On Cat-M1 with ~37 KB/s practical uplink, a 12 KB batch takes about 0.32 seconds to transmit. With 4 channels of f32 at 100 Hz, you produce 4 * 100 * 12 = 4,800 bytes/second of raw XMBP data. That fits comfortably in the Cat-M1 bandwidth, leaving room for HTTP overhead.

For higher sample rates, consider these strategies: - Use fewer channels per device (split across multiple physical devices). - Lower the sample rate for less-critical streams (e.g., temperature at 10 Hz instead of 100 Hz). - Batch more aggressively: 500 samples/batch at 100 Hz = one POST every 5 seconds.


17. RBAC Reference

Roles

Role Level Description
super_admin 3 Full system access across all facilities
facility_admin 2 Manage users, keys, devices, and data within their facility
user 1 Read access to data within their facility

Role checks use >= comparison, so a super_admin passes any role check.

Permission Matrix

Endpoint super_admin facility_admin user
Facilities CRUD Yes -- --
Users CRUD Yes Own facility --
API Keys CRUD Yes Own facility --
Devices List/Get Yes Yes Yes
Devices Create/Update Yes Own facility --
Uploads List/Get Yes Yes Yes
Uploads Create (API Key) Via API Key Via API Key Via API Key
Uploads Update/Delete Yes Yes Yes
Uploads Retranscode Yes Yes Yes
Streams Get Yes Yes Yes
Transcode Jobs List/Get Yes Yes Yes
Transcode Jobs Cancel Yes Own facility --
Tags List Yes Yes Yes
Tags Create/Delete Yes Own facility --
Ingest (API Key) Via API Key Via API Key Via API Key
Metadata Query Yes Yes Yes
Config CRUD Yes -- --
Stats Overview Yes Yes Yes

Facility Isolation

  • Non-Super Admin users can only access data within their assigned facility.
  • API Keys are scoped to a single facility.
  • Cross-facility access attempts return 403 Forbidden.

18. Error Responses

All errors follow a standard JSON format:

{
  "error": "human-readable error message"
}

HTTP Status Codes

Status Meaning Common Causes
400 Bad Request Invalid request Missing fields, invalid JSON, XMBP decode error
401 Unauthorized Authentication failed Invalid/missing JWT or API key, expired token
403 Forbidden Insufficient permissions Wrong role, cross-facility access
404 Not Found Resource not found Invalid ID, deleted resource
409 Conflict Resource conflict Duplicate email, duplicate slug, duplicate device_uid
500 Internal Server Error Server error Database error, S3 error, unexpected failure

Example Error Responses

401 -- Invalid credentials:

{"error": "invalid email or password"}

403 -- Wrong role:

{"error": "requires role: super_admin"}

404 -- Missing resource:

{"error": "facility b7d4f1a8-2e6c-4b39-a8d1-9f3e5c7b2a14 not found"}

409 -- Name collision:

{"error": "facility with slug 'seoul-acoustics' already exists"}


19. Data Models

Facility

{
  "id": "UUID",
  "name": "string",
  "slug": "string",
  "description": "string | null",
  "is_active": "boolean",
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601"
}

User

{
  "id": "UUID",
  "facility_id": "UUID | null",
  "email": "string",
  "display_name": "string",
  "role": "super_admin | facility_admin | user",
  "is_active": "boolean",
  "last_login_at": "ISO 8601 | null",
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601"
}

ApiKey

{
  "id": "UUID",
  "facility_id": "UUID",
  "key_prefix": "string",
  "name": "string",
  "scopes": ["string"],
  "is_active": "boolean",
  "expires_at": "ISO 8601 | null",
  "last_used_at": "ISO 8601 | null",
  "created_by": "UUID | null",
  "created_at": "ISO 8601"
}

Device

{
  "id": "UUID",
  "facility_id": "UUID",
  "device_uid": "integer",
  "name": "string",
  "hardware_version": "string | null",
  "firmware_version": "string | null",
  "is_active": "boolean",
  "last_seen_at": "ISO 8601 | null",
  "created_at": "ISO 8601"
}

Upload

{
  "id": "UUID",
  "facility_id": "UUID",
  "api_key_id": "UUID | null",
  "uploaded_by": "UUID | null",
  "original_filename": "string",
  "original_format": "string",
  "original_size_bytes": "integer",
  "duration_ms": "integer | null",
  "sample_rate_hz": "integer | null",
  "channels": "integer | null",
  "bit_depth": "integer | null",
  "codec": "string | null",
  "metadata": "object",
  "status": "pending | processing | completed | failed",
  "tags": ["Tag"],
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601"
}

TranscodeJob

{
  "id": "UUID",
  "upload_id": "UUID",
  "facility_id": "UUID",
  "target_format": "string",
  "target_codec": "string",
  "target_container": "string",
  "target_bitrate": "string | null",
  "target_sample_rate": "integer | null",
  "output_size_bytes": "integer | null",
  "output_duration_ms": "integer | null",
  "status": "queued | running | completed | failed | cancelled",
  "error_message": "string | null",
  "attempts": "integer",
  "max_attempts": "integer",
  "queued_at": "ISO 8601",
  "started_at": "ISO 8601 | null",
  "completed_at": "ISO 8601 | null",
  "worker_id": "string | null"
}

Tag

{
  "id": "UUID",
  "facility_id": "UUID",
  "name": "string",
  "color": "string | null",
  "created_at": "ISO 8601"
}

IngestSession

{
  "id": "UUID",
  "facility_id": "UUID",
  "device_id": "UUID",
  "status": "active | closing | closed | error",
  "started_at": "ISO 8601",
  "total_samples": "integer",
  "total_bytes": "integer",
  "missed_batches": "integer",
  "streams": ["MetadataStream"]
}

MetadataStream

{
  "id": "UUID",
  "stream_index": "integer",
  "name": "string",
  "value_type": "f32 | f64 | i32 | i64 | bool | string | bytes | f64_array | f32_array | i32_array | json | i16 | i8",
  "unit": "string | null",
  "sample_rate_hz": "float | null"
}

ConfigEntry

{
  "key": "string",
  "value": "any (JSON)",
  "category": "string",
  "value_type": "string",
  "description": "string | null",
  "updated_at": "ISO 8601"
}

20. Pagination

Paginated endpoints use consistent query parameters and response format.

Query Parameters

Parameter Type Default Range Description
page integer 1 >= 1 Page number (1-based)
per_page integer 20 1-100 Items per page

Response Format

{
  "items": [...],
  "total": 142,
  "page": 1,
  "per_page": 20
}
Field Type Description
items array Data items for the current page
total integer Total number of items across all pages
page integer Current page number
per_page integer Items per page

Paginated Endpoints

  • GET /api/v1/uploads
  • GET /api/v1/transcode-jobs
  • GET /api/v1/metadata/sessions

21. Example Workflows

Complete Setup Flow

Set up a new facility from scratch: create the facility, add a user, generate an API key, and upload the first recording.

# 1. Log in as Super Admin to get a JWT
TOKEN=$(curl -s -X POST https://api.xylolabs.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"minseok.jeon@xylolabs.com","password":"admin-password"}' \
  | jq -r '.access_token')

# 2. Create the facility
FACILITY_ID=$(curl -s -X POST https://api.xylolabs.com/api/v1/facilities \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "페리지에어로스페이스 옥천공장",
    "slug": "perigee-okcheon",
    "description": "옥천 생산공장 음향 모니터링"
  }' | jq -r '.id')
echo "Created facility: $FACILITY_ID"

# 3. Create a facility admin who'll manage day-to-day operations
USER_ID=$(curl -s -X POST https://api.xylolabs.com/api/v1/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"email\": \"minseok.jeon@xylolabs.com\",
    \"password\": \"Kj8mP2xvNq!\",
    \"display_name\": \"전민석\",
    \"role\": \"facility_admin\",
    \"facility_id\": \"$FACILITY_ID\"
  }" | jq -r '.id')
echo "Created user: $USER_ID"

# 4. Log in as the new facility admin
FA_TOKEN=$(curl -s -X POST https://api.xylolabs.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"minseok.jeon@xylolabs.com","password":"Kj8mP2xvNq!"}' \
  | jq -r '.access_token')

# 5. Generate an API key for the sensor device in the field
API_KEY=$(curl -s -X POST https://api.xylolabs.com/api/v1/api-keys \
  -H "Authorization: Bearer $FA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"옥천공장 센서 유닛 #1","scopes":["upload","ingest"]}' \
  | jq -r '.key')
echo "API key (save this!): $API_KEY"

# 6. Upload the first field recording using the API key
curl -X POST https://api.xylolabs.com/api/v1/uploads \
  -H "X-Api-Key: $API_KEY" \
  -F "file=@okcheon_factory_2025-06-15.flac" \
  -F 'metadata={"source":"sensor-array","location":"옥천공장 생산라인 A","facility":"perigee-okcheon"}'

# 7. Check that the upload showed up and transcoding started
curl -s "https://api.xylolabs.com/api/v1/uploads?page=1" \
  -H "Authorization: Bearer $FA_TOKEN" | jq '.items[0] | {status, original_filename}'

Device Metadata Streaming Flow

Create an ingest session, send XMBP sensor data, close the session, and export the results.

# 1. Start a monitoring session with vibration and temperature streams
SESSION_ID=$(curl -s -X POST https://api.xylolabs.com/api/v1/ingest/sessions \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "device_uid": 1,
    "name": "Turbine #7 morning run 2025-06-16",
    "streams": [
      {"stream_index":0,"name":"vibration_x","value_type":"f32","unit":"g","sample_rate_hz":500},
      {"stream_index":1,"name":"vibration_y","value_type":"f32","unit":"g","sample_rate_hz":500},
      {"stream_index":2,"name":"bearing_temp","value_type":"f32","unit":"celsius","sample_rate_hz":10}
    ],
    "metadata": {"location":"Hall B, Turbine #7","firmware":"2.1.0"}
  }' | jq -r '.id')
echo "Session started: $SESSION_ID"

# 2. Send XMBP binary batches in a loop (your device firmware does this)
#    Each batch contains 250 samples at 500 Hz = one batch every 0.5 seconds
for i in $(seq 1 100); do
  curl -s -X POST "https://api.xylolabs.com/api/v1/ingest/sessions/$SESSION_ID/data" \
    -H "X-Api-Key: $API_KEY" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @batch_${i}.bin > /dev/null
done

# 3. Close the session when the monitoring period ends
curl -s -X POST "https://api.xylolabs.com/api/v1/ingest/sessions/$SESSION_ID/close" \
  -H "X-Api-Key: $API_KEY" | jq '{status, total_samples, total_bytes, missed_batches}'

# 4. Review the session from the dashboard
curl -s "https://api.xylolabs.com/api/v1/metadata/sessions/$SESSION_ID" \
  -H "Authorization: Bearer $TOKEN" | jq '.streams[] | {name, sample_rate_hz}'

# 5. Export all session data as CSV for analysis
curl "https://api.xylolabs.com/api/v1/metadata/sessions/$SESSION_ID/export?format=csv" \
  -H "Authorization: Bearer $TOKEN" \
  --output turbine7_2025-06-16.csv
echo "Exported to turbine7_2025-06-16.csv"

Admin Configuration Flow

Check current transcode settings, change them, verify, and revert if needed.

# 1. Log in as Super Admin
TOKEN=$(curl -s -X POST https://api.xylolabs.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"minseok.jeon@xylolabs.com","password":"admin-password"}' \
  | jq -r '.access_token')

# 2. See what format uploads are being transcoded to
curl -s https://api.xylolabs.com/api/v1/config/transcode.default_format \
  -H "Authorization: Bearer $TOKEN" | jq '.value'
# "opus_webm"

# 3. Switch to AAC/MP4 for better Safari compatibility
curl -s -X PUT https://api.xylolabs.com/api/v1/config/transcode.default_format \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value":"aac_mp4"}' | jq '.value'
# "aac_mp4"

# 4. Changed your mind? Reset to the default
curl -s -X POST https://api.xylolabs.com/api/v1/config/reset/transcode.default_format \
  -H "Authorization: Bearer $TOKEN" | jq '.value'
# "opus_webm"

# 5. Check the dashboard stats while you're here
curl -s https://api.xylolabs.com/api/v1/stats/overview \
  -H "Authorization: Bearer $TOKEN"
# {"users":47,"uploads":1284,"facilities":5}

Field Deployment with Cat-M1 Devices

End-to-end workflow for deploying Cat-M1-connected sensors at a remote field station. This covers provisioning, firmware configuration, and data retrieval.

# === PROVISIONING (done from your laptop before going to the field) ===

# 1. Log in
TOKEN=$(curl -s -X POST https://api.xylolabs.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"minseok.jeon@xylolabs.com","password":"admin-password"}' \
  | jq -r '.access_token')

# 2. Create the remote facility
FACILITY_ID=$(curl -s -X POST https://api.xylolabs.com/api/v1/facilities \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "페리지에어로스페이스 미래기술연구소",
    "slug": "perigee-futuretech",
    "description": "미래기술연구소 센서 데이터 수집"
  }' | jq -r '.id')

# 3. Create a facility admin for the field team
curl -s -X POST https://api.xylolabs.com/api/v1/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"email\": \"minseok.jeon@xylolabs.com\",
    \"password\": \"FieldWork2025!\",
    \"display_name\": \"전민석\",
    \"role\": \"facility_admin\",
    \"facility_id\": \"$FACILITY_ID\"
  }" > /dev/null

# 4. Log in as the field team lead
FA_TOKEN=$(curl -s -X POST https://api.xylolabs.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"minseok.jeon@xylolabs.com","password":"FieldWork2025!"}' \
  | jq -r '.access_token')

# 5. Generate API keys for three Cat-M1 sensor units
for i in 1 2 3; do
  KEY=$(curl -s -X POST https://api.xylolabs.com/api/v1/api-keys \
    -H "Authorization: Bearer $FA_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"name\":\"Cat-M1 Unit #${i}\",\"scopes\":[\"upload\",\"ingest\"]}" \
    | jq -r '.key')
  echo "Unit #${i} API key: $KEY"
  # Flash this key into each unit's firmware config
done

# 6. Pre-register the devices with hardware info
curl -s -X POST https://api.xylolabs.com/api/v1/devices \
  -H "Authorization: Bearer $FA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"device_uid":1,"name":"Corridor North Recorder","hardware_version":"nrf9160-dk","firmware_version":"1.0.3"}' > /dev/null

curl -s -X POST https://api.xylolabs.com/api/v1/devices \
  -H "Authorization: Bearer $FA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"device_uid":2,"name":"Stream Crossing Hydrophone","hardware_version":"bg96-shield","firmware_version":"0.9.1"}' > /dev/null

curl -s -X POST https://api.xylolabs.com/api/v1/devices \
  -H "Authorization: Bearer $FA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"device_uid":3,"name":"Hillside Bat Detector","hardware_version":"nrf9160-dk","firmware_version":"1.0.3"}' > /dev/null

# === WHAT THE DEVICE FIRMWARE DOES (Cat-M1 units in the field) ===
# Each unit powers on, attaches to the Cat-M1 network, and runs this cycle:
#
# a) POST /api/v1/ingest/sessions  -- create session (once at boot)
# b) Build XMBP batch (250 samples of f32 at 100 Hz = every 2.5 seconds)
# c) POST /api/v1/ingest/sessions/{id}/data  -- send XMBP batch
# d) Repeat (b)-(c) for the recording period
# e) POST /api/v1/ingest/sessions/{id}/close  -- close session
# f) Enter PSM (power save mode) for 4 hours, then repeat from (a)

# === DATA RETRIEVAL (back at the office, days later) ===

# 7. Check which sessions came in from the field
curl -s "https://api.xylolabs.com/api/v1/metadata/sessions?status=closed&per_page=50" \
  -H "Authorization: Bearer $FA_TOKEN" \
  | jq '.items[] | {id: .id, device: .device_id, samples: .total_samples, missed: .missed_batches}'

# 8. Export a session for analysis
SESSION_ID="<pick a session id from the list above>"
curl "https://api.xylolabs.com/api/v1/metadata/sessions/$SESSION_ID/export?format=csv" \
  -H "Authorization: Bearer $FA_TOKEN" \
  --output dmz_corridor_session.csv

22. LTE Cat-M1 Integration Guide

Cat-M1 (LTE-M) is a low-power wide-area cellular technology designed for IoT devices. It works well for Xylolabs sensor deployments in locations without WiFi or Ethernet, like remote field stations, offshore buoys, and rural monitoring posts.

This section covers modem selection, bandwidth planning, AT command examples, and deployment tips specific to using Xylolabs with Cat-M1 connectivity.

Supported Modems

Any modem that can issue HTTP requests over Cat-M1 will work with Xylolabs. These are the modules we've tested or that community members have used successfully:

Module Manufacturer Interface Power Features Notes
BG770A Quectel UART AT commands PSM, eDRX Cat-M1/NB-IoT with excellent power management.
SARA-R410M / R412M u-blox UART AT commands PSM, eDRX Excellent power management documentation. R412M adds NB-IoT fallback.
nRF9160 Nordic Semiconductor Integrated SoC PSM, eDRX System-in-package with ARM Cortex-M33 + modem. Use Nordic's SDK or AT commands via the dedicated AT client firmware.
HL7800 / HL7812 Sierra Wireless UART AT commands PSM, eDRX Carrier-certified on major US/EU networks. Good for deployments requiring carrier approval.
Type 1SC Murata UART AT commands PSM, eDRX Very compact (10.5 x 9.8 mm). Based on the ALT1250 chipset. Good for space-constrained PCB designs.

For new projects, the nRF9160 is the easiest path if you want a single chip doing both sensing and cellular. If you already have a microcontroller and need to add connectivity, the Quectel BG770A has the most community resources and example code.

Bandwidth Budget

Cat-M1 provides roughly 375 kbps theoretical uplink, but in practice you should plan for about 300 kbps (~37 KB/s) after protocol overhead and real-world conditions.

How many channels of f32 at 100 Hz can you fit?

Each f32 sample in XMBP costs 12 bytes (8 bytes timestamp + 4 bytes value):

Per channel:     100 samples/sec * 12 bytes/sample = 1,200 bytes/sec
With 4 channels: 4 * 1,200 = 4,800 bytes/sec
With header overhead (~14 bytes/batch + 5 bytes/stream + HTTP headers):
  Roughly 5,200 bytes/sec total

Available uplink: ~37,000 bytes/sec
Utilization:      5,200 / 37,000 = 14%

That leaves plenty of headroom. You could push up to ~25 channels of f32 at 100 Hz before saturating the link, though you'll want to keep utilization below 70% to handle retransmissions and latency spikes.

Recommended batch sizing for Cat-M1:

Sample Rate Samples per Batch Send Interval Batch Size (4 ch) Cat-M1 TX Time
10 Hz 50 5.0 sec ~2.5 KB ~70 ms
100 Hz 250 2.5 sec ~12 KB ~330 ms
500 Hz 500 1.0 sec ~24 KB ~650 ms
1000 Hz 1000 1.0 sec ~48 KB ~1.3 sec

For battery-powered devices, bigger batches sent less frequently are more energy-efficient because the modem spends less time in active TX mode.

AT Command Examples (Quectel BG770A)

These examples show the complete AT command sequence for connecting a BG770A to the cellular network and posting XMBP data to Xylolabs. Adapt as needed for other modems (the AT commands are similar across vendors).

1. Network Registration and Attachment

# Check if the SIM is ready
AT+CPIN?
+CPIN: READY

# Set the APN for your carrier (example: SKT Korea)
AT+CGDCONT=1,"IP","lte.sktelecom.com"
OK

# Enable Cat-M1 mode only (disable NB-IoT)
AT+QCFG="nwscanseq",02
OK
AT+QCFG="iotopmode",0
OK

# Trigger network registration
AT+COPS=0
OK

# Wait for registration (poll until +CREG: 0,1 or 0,5)
AT+CREG?
+CREG: 0,1          <-- 1 = registered (home), 5 = registered (roaming)

# Verify data attachment
AT+CGATT?
+CGATT: 1            <-- 1 = attached to packet domain

2. PDP Context Activation

# Activate the PDP context for data
AT+QIACT=1
OK

# Verify IP assignment
AT+QIACT?
+QIACT: 1,1,1,"10.47.128.33"
#                ^^^^^^^^^^^^^ your assigned IP address

3. HTTP POST for XMBP Data Upload

# Configure HTTP context
AT+QHTTPCFG="contextid",1
OK

# Set the target URL for data upload
AT+QHTTPCFG="sslctxid",1
AT+QSSLCFG="seclevel",1,0
AT+QHTTPURL=82,10
CONNECT
https://api.xylolabs.com/api/v1/ingest/sessions/<SESSION_ID>/data
OK

# Set custom headers: API key and content type
AT+QHTTPCFG="requestheader",1
OK

# Prepare and send the XMBP binary payload
# The raw data must be preceded by the HTTP headers
AT+QHTTPPOST=<total_size>,30,30
CONNECT
POST /api/v1/ingest/sessions/<SESSION_ID>/data HTTP/1.1\r\n
Host: api.xylolabs.com\r\n
X-Api-Key: xk_t4r78k2m5p7r...\r\n
Content-Type: application/octet-stream\r\n
Content-Length: <xmbp_batch_size>\r\n
\r\n
<raw XMBP binary bytes here>

OK
+QHTTPPOST: 0,200      <-- 200 = success

4. HTTP POST for Audio File Upload (Chunked)

For large audio files over Cat-M1, you'll need to send the multipart form data in chunks. The BG770A has a limited RAM buffer (typically 4-10 KB for AT+QHTTPPOST), so you'll need to use chunked transfer or split the file.

# For files under 10 KB, you can use a single POST:
AT+QHTTPURL=55,10
CONNECT
https://api.xylolabs.com/api/v1/uploads
OK

# Build multipart body in your MCU's memory, then:
AT+QHTTPPOST=<total_size>,60,60
CONNECT
POST /api/v1/uploads HTTP/1.1\r\n
Host: api.xylolabs.com\r\n
X-Api-Key: xk_t4r78k2m5p7r...\r\n
Content-Type: multipart/form-data; boundary=XyloUpload2025\r\n
Content-Length: <body_size>\r\n
\r\n
--XyloUpload2025\r\n
Content-Disposition: form-data; name="file"; filename="hydrophone_reef_sample.flac"\r\n
Content-Type: audio/flac\r\n
\r\n
<FLAC file bytes>\r\n
--XyloUpload2025\r\n
Content-Disposition: form-data; name="metadata"\r\n
\r\n
{"source":"bg770a-hydrophone","depth_m":12}\r\n
--XyloUpload2025--\r\n

OK
+QHTTPPOST: 0,200

# For files larger than the AT buffer, use your MCU to split the file
# into chunks and use AT+QHTTPPOSTFILE with a file stored on the modem's
# filesystem, or implement HTTP chunked transfer encoding in your firmware.

5. Power Save Mode (PSM) Configuration

PSM lets the modem sleep for extended periods while keeping its network registration. This is critical for battery-powered field deployments.

# Request PSM with specific timers:
#   T3324 (Active Timer) = 20 seconds  -- how long the modem stays reachable after sending data
#   T3412 (TAU Timer)    = 4 hours     -- how long until the next Tracking Area Update
#
# Timer encoding (3GPP TS 24.008):
#   T3324: "00000100" = 4 * 2s multiples = 20 seconds (bits 7-6: 00 = 2s unit)
#                       Wait, let's use the right encoding:
#   T3324: "00001010" = 10 * 2s = 20 seconds
#   T3412: "00100100" = 4 * 1h = 4 hours (bits 7-6: 00 = 10min... )
#
# Simpler: just ask for the values and let the network negotiate
AT+CPSMS=1,,,"00100100","00001010"
OK

# Verify what the network granted
AT+CPSMS?
+CPSMS: 1,,,"00100100","00001010"
#            T3412=4h    T3324=20s

# Your firmware loop should look like:
#   1. Wake from PSM (modem auto-attaches)
#   2. Create session or resume
#   3. Collect and send data batches
#   4. Close session
#   5. Enter PSM: AT+QSCLK=1 (the modem handles the rest)
#   6. MCU goes to deep sleep, RTC wakes it after desired interval

Example: Field Deployment Workflow

This walks through a realistic deployment scenario with Cat-M1 devices.

Scenario: You're deploying three battery-powered sensors along a remote river valley. Each unit has an nRF9160 modem, a MEMS accelerometer, a temperature sensor, and a microphone. They'll wake every 4 hours, record 10 minutes of data, upload it, and go back to sleep.

Phase 1: Office preparation

Flash each unit with: - The facility's API key (from step 5 in the provisioning workflow above) - The APN for your SIM carrier - Stream definitions (accelerometer x/y/z at 100 Hz, temperature at 1 Hz) - Recording duration (10 minutes) - Sleep interval (4 hours)

Phase 2: What the firmware does each cycle

Boot
  |
  +-- Initialize modem (AT+CFUN=1)
  |
  +-- Wait for CREG: 0,1 (registered)
  |
  +-- Activate PDP context (AT+QIACT=1)
  |
  +-- POST /ingest/sessions (create session with 4 streams)
  |     Save the session_id in RAM
  |
  +-- Start sampling loop:
  |     Collect 250 samples (~2.5 sec at 100 Hz)
  |     Build XMBP batch in RAM (~12 KB)
  |     POST /ingest/sessions/{id}/data
  |     Check HTTP 200, increment batch_seq
  |     Repeat for 10 minutes (240 batches)
  |
  +-- POST /ingest/sessions/{id}/close
  |
  +-- (Optional) POST /uploads with a 10-min audio clip
  |     Only if there's enough battery and the file is small enough
  |
  +-- Enter PSM (AT+CPSMS=1, then deep sleep)
  |
  +-- [4 hours pass, RTC alarm fires, back to Boot]

Phase 3: Data retrieval from your desk

# Log in and check how many sessions came in overnight
curl -s "https://api.xylolabs.com/api/v1/metadata/sessions?status=closed&per_page=100" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '[.items[] | {device: .device_id, started: .started_at, samples: .total_samples, missed: .missed_batches}]'

# Output:
# [
#   {"device":"..north..","started":"2025-06-16T02:00:00Z","samples":96000,"missed":0},
#   {"device":"..north..","started":"2025-06-16T06:00:00Z","samples":96000,"missed":1},
#   {"device":"..crossing..","started":"2025-06-16T02:05:00Z","samples":96000,"missed":0},
#   ...
# ]

Battery Life Estimation

For a typical deployment with a 3.7V 6000 mAh LiPo battery:

Component Current Draw Time per Cycle Energy per Cycle
MCU active + sensing ~15 mA 10 min 2.5 mAh
Modem TX (Cat-M1) ~200 mA avg ~3 min total 10 mAh
Modem idle (between POSTs) ~10 mA ~7 min 1.2 mAh
PSM sleep (modem + MCU) ~10 uA 3 hr 50 min 0.64 mAh
Total per cycle 4 hours ~14.3 mAh

At 6 cycles per day (every 4 hours): 85.8 mAh/day. Battery life: ~70 days (6000 mAh / 85.8 mAh/day), before accounting for self-discharge and temperature effects.

In cold weather (below -10 C), expect 30-50% less battery capacity. Plan for swaps or solar charging for extended deployments.