SDK
A typed TypeScript client for the Cymo API: one method per operation, async iterators for lists and streams, and webhook verification.
Install
@cymos/sdk is an ES module for Node.js 20.19 or later. Keep it on your server: the key it holds can read your rooms.
Create a client
Create a key in the dashboard and pass it as apiKey. A test key reads the simulator and a live key reads your paired Cymos; the code is the same.
import { Cymo } from "@cymos/sdk";
const cymo = new Cymo({
apiKey: process.env.CYMO_API_KEY!, // cym_test_… or cym_live_…
timeoutMs: 10_000,
maxRetries: 2,
client: "my-app/1.2.0",
});apiKeystring, required | A cym_test_… or cym_live_… key. |
baseUrldefault https://api.cymatics.tech | An HTTPS origin, or HTTP on localhost for a local simulator. |
mode"test" | "live" | Inferred from the key's prefix. A response from the other mode throws mode_mismatch. |
timeoutMsdefault 10000 | Per request, from 1 ms to one hour. |
maxRetriesdefault 2 | Automatic retries, from 0 to 5. See Errors and retries below. |
clientdefault sdk/<version> | Sent as X-Cymo-Client, so request logs show which app made each call. |
fetchtypeof fetch | A custom fetch, for tests or proxies. |
Quickstart
import { Cymo } from "@cymos/sdk";
const cymo = new Cymo({ apiKey: process.env.CYMO_API_KEY! });
const { data, meta } = await cymo.vitals.get({ room_id: "room_bedroom" });
for (const person of data.people) {
console.log(person.track_id, person.signals.heart_rate_bpm?.value);
}
console.log(meta.livemode); // false: test keys read the simulatorResource methods
Every API operation is a method named after its resource. Path parameters, query parameters and the request body go in one object, checked against the contract before anything is sent, so a typo throws invalid_arguments instead of reaching the API. Each API reference page shows its method.
const { data, meta } = await cymo.scenarioRuns.create({
scenario_id: "bed_exit",
speed: 1,
seed: 42
});cymo.rooms.get() | GET /v1/rooms/{room} |
cymo.vitals.get() | GET /v1/vitals |
cymo.results.list()also cymo.results.listAll() | GET /v1/results |
cymo.events.list()also cymo.events.listAll() | GET /v1/events |
cymo.rooms.stream() | GET /v1/rooms/{room}/stream |
cymo.webhooks.create() | POST /v1/webhooks |
cymo.scenarioRuns.create() | POST /v1/scenario-runs |
cymo.scenarioRuns.control() | POST /v1/scenario-runs/{run}/control |
cymo.rawData.exports.download() | GET /v1/raw-data/exports/{export}/content |
cymo.usage.get() | GET /v1/usage |
Responses
Methods resolve to the API's envelope unchanged: { data, meta }, and lists add page. Field names match the REST API exactly, in snake_case.
const { data, meta } = await cymo.rooms.get({ room_id: "room_bedroom" });
data.people; // RoomSnapshot, typed from the contract
meta.request_id; // quote it to support
meta.livemode; // false for test keysPagination
list returns one page. listAll is an async iterator over every item: it requests the next page only when you reach it, stops when you break, and throws invalid_response if a cursor fails to advance.
// One page: data, plus page.has_more and page.next_cursor
const { data, page } = await cymo.events.list({ room_id: "room_bedroom", limit: 100 });
// Every page, requested as you iterate
for await (const event of cymo.events.listAll({ room_id: "room_bedroom" })) {
console.log(event.type, event.timestamp);
}Streams
rooms.stream, people.stream and vitals.stream return async iterators of { data, meta, id, kind }. After a dropped connection the SDK reconnects with Last-Event-ID, up to maxReconnects times (default 2), so no message is lost; API errors are thrown straight away. Pass lastEventId to resume a stream you closed, and an AbortSignal to stop one.
let lastEventId: string | undefined;
const controller = new AbortController();
for await (const message of cymo.rooms.stream(
{ room_id: "room_bedroom" },
{ signal: controller.signal, maxReconnects: 5 },
)) {
lastEventId = message.id;
console.log(message.kind, message.data);
}
// Later: pick up after the last message you handled
cymo.rooms.stream({ room_id: "room_bedroom" }, { lastEventId });
// Heart rate and respiration only, from the same room stream
cymo.vitals.stream({ room_id: "room_bedroom" });Streams also take reconnectDelayMs, idleTimeoutMs (default 30 s without a byte, then stream_idle_timeout) and maxMessageBytes (default 1 MiB). See Streaming for the wire format.
Binary downloads
Raw-data downloads and raw device streams yield Uint8Array chunks. maxBytes stops after that many bytes, and the SDK checks the response belongs to your key's mode before yielding any.
for await (const chunk of cymo.rawData.exports.download({
export_id: "exp_01"
}, { maxBytes: 1_048_576 })) {
process.stdout.write(chunk);
}Errors and retries
Every failure is a CymoError with the API's stable code, or one of the SDK's own, such as timeout, network_error, cancelled or redirect_refused. The key is never sent through a redirect.
import { CymoError } from "@cymos/sdk";
try {
await cymo.rooms.get({ room_id: "room_missing" });
} catch (error) {
if (!(error instanceof CymoError)) throw error;
error.code; // "resource_not_found"
error.status; // 404
error.request_id; // "req_…"
error.retry_after_ms; // from Retry-After, when sent
error.rate_limit; // { limit, remaining, reset_ms }
error.details; // invalid fields, for example
}Within maxRetries, the SDK retries rate_limit_exceeded on any request after Retry-After, up to 30 seconds. Reads and requests with an idempotency key also retry network failures and 502, 503 and 504. It never retries quota_exceeded or live_unavailable. See Errors for every code.
Idempotency
Every method takes a second argument for signal, timeoutMs, maxRetries and idempotencyKey. With a key, a write is safe to retry: the SDK retries it like a read, and the API replays the first response instead of acting twice.
await cymo.webhooks.create(
{ url: "https://example.com/api/cymo-webhook", events: ["bed.exited"] },
{ idempotencyKey: crypto.randomUUID() },
);Verify webhooks
verifyWebhookSignature checks a delivery's HMAC-SHA256 signature in constant time and rejects a signing time outside the tolerance.
import { verifyWebhookSignature } from "@cymos/sdk";
// Resolves when valid; otherwise throws CymoError with code
// invalid_webhook_signature or webhook_timestamp_out_of_tolerance.
await verifyWebhookSignature({
payload, // string | Uint8Array: the raw body exactly as received
header, // the Cymo-Signature header
deliveryId, // the Cymo-Delivery-Id header
secret, // string | string[]: any one may match, while you rotate
toleranceSec, // optional, default 300
now, // optional, epoch milliseconds, default Date.now()
});Integrations has a complete Next.js route handler, and Events and webhooks explains the scheme.
TypeScript types
Arguments, responses and stream messages are typed from the same contract as the API, including domain types such as RoomSnapshot, Result, Event and ScenarioRun. Index OperationArgs and OperationResponses by operation ID to type your own wrappers.
import type {
Envelope,
ListEnvelope,
StreamMessage,
RoomSnapshot,
Event,
OperationArgs,
OperationResponses,
} from "@cymos/sdk";
type CreateRunArgs = OperationArgs["create-scenario-run"];
type Room = OperationResponses["get-room"]; // Envelope<RoomSnapshot>