API
Contents
Zoiva API

Your radars' data, in your systems.

Organise your radars into houses, receive a summary of each radar every five minutes, and fetch second-level data whenever you need it. HTTPS and JSON throughout.

Last updated 2 November 2026

Five-minute integration, for now. Zoiva is still developing. Today, integrations run on a five-minute summary of each radar, pushed to your webhook. Second-level data is still yours to fetch at any time: the last 3 days with /recent and the full history with /samples. Within /v1 we only add things, so what you build keeps working.

Base URL
Your api_url: https://<your-org>.api.zoiva-radar.com. Every route is under /v1.
Sign-in
OAuth 2.0 client credentials at your token_url. A token lasts 15 minutes.
Format
JSON over HTTPS (TLS 1.2 or later). Times in ISO 8601, UTC.
Pushed to you
A data.summary event per radar every five minutes, plus radar events, by signed webhook.
On request
/recent (last 3 days, second by second), /samples (full history) and /queries (your own SQL).
You receive
An admin API client (api_url, token_url, client_id, client_secret), sent once through a link that works only once. Zoiva installs the radars.

Keep secrets on your servers. Client secrets and webhook secrets belong in a secret store. Never put them in a browser, a mobile app, a code repository or a log.

Quick startFive steps to your first data

  1. Get an access token

    Exchange your client id and secret for a token, then send it on every request. Reuse it until it nearly expires. In these examples the API URL, token URL, client id and secret come from your credentials.

    curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=client_credentials "$TOKEN_URL"
    # {"access_token": "eyJ...", "expires_in": 900, "token_type": "Bearer"}
    
    curl -s -H "Authorization: Bearer $TOKEN" "$API_URL/v1/houses"
    # pip install httpx
    import httpx
    
    token = httpx.post(TOKEN_URL, auth=(CLIENT_ID, CLIENT_SECRET), data={"grant_type": "client_credentials"}).json()
    
    houses = httpx.get(f"{API_URL}/v1/houses", headers={"Authorization": f"Bearer {token['access_token']}"})
    print(houses.json())
    // Node.js 18 or later, no packages needed. Save as token.mjs
    const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
    const token = await fetch(TOKEN_URL, {
      method: "POST",
      headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
      body: "grant_type=client_credentials",
    }).then((r) => r.json());
    
    const houses = await fetch(`${API_URL}/v1/houses`, {
      headers: { Authorization: `Bearer ${token.access_token}` },
    }).then((r) => r.json());
    console.log(houses);
    // .NET 8, no packages needed
    using System.Net.Http.Headers;
    using System.Net.Http.Json;
    using System.Text;
    using System.Text.Json;
    
    var http = new HttpClient();
    var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
    var tokenRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
    {
        Headers = { Authorization = new AuthenticationHeaderValue("Basic", basic) },
        Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["grant_type"] = "client_credentials" }),
    };
    var tokenResponse = await http.SendAsync(tokenRequest);
    tokenResponse.EnsureSuccessStatusCode();
    var token = (await tokenResponse.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("access_token").GetString();
    
    http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
    Console.WriteLine(await http.GetStringAsync($"{apiUrl}/v1/houses"));
    // Java 11 or later, with Jackson for the JSON
    import com.fasterxml.jackson.databind.ObjectMapper;
    import java.net.URI;
    import java.net.http.HttpClient;
    import java.net.http.HttpRequest;
    import java.net.http.HttpResponse;
    import java.nio.charset.StandardCharsets;
    import java.util.Base64;
    
    var http = HttpClient.newHttpClient();
    var basic = Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
    var tokenResponse = http.send(HttpRequest.newBuilder(URI.create(tokenUrl))
            .header("Authorization", "Basic " + basic)
            .header("Content-Type", "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString("grant_type=client_credentials")).build(),
        HttpResponse.BodyHandlers.ofString());
    String token = new ObjectMapper().readTree(tokenResponse.body()).get("access_token").asText();
    
    var houses = http.send(HttpRequest.newBuilder(URI.create(apiUrl + "/v1/houses"))
            .header("Authorization", "Bearer " + token).build(),
        HttpResponse.BodyHandlers.ofString());
    System.out.println(houses.body());
    // Go standard library only (net/http, net/url, strings, encoding/json, io, fmt, log)
    form := url.Values{"grant_type": {"client_credentials"}}
    req, _ := http.NewRequest("POST", tokenURL, strings.NewReader(form.Encode()))
    req.SetBasicAuth(clientID, clientSecret)
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    var token struct {
        AccessToken string `json:"access_token"`
    }
    json.NewDecoder(resp.Body).Decode(&token)
    resp.Body.Close()
    
    req, _ = http.NewRequest("GET", apiURL+"/v1/houses", nil)
    req.Header.Set("Authorization", "Bearer "+token.AccessToken)
    resp, err = http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    fmt.Println(string(body))
  2. Replace the client you received

    Create your own admin client, then delete the one Zoiva sent. After that, no secret that was ever sent to you still works.

    curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
         -d '{"name": "admin", "scopes": ["zoiva/clients.write", "zoiva/houses.read", "zoiva/houses.write",
              "zoiva/devices.read", "zoiva/devices.write", "zoiva/data.read", "zoiva/webhooks.write"]}' \
         "$API_URL/v1/clients"
    # {"client_id": "...", "client_secret": "...", ...}   the secret is shown only now
    
    # with a token from the new client, delete the one you received
    curl -s -X DELETE -H "Authorization: Bearer $NEW_TOKEN" "$API_URL/v1/clients/$RECEIVED_CLIENT_ID"

    Give each of your systems its own client with only the scopes it needs. You can have 3 clients. A new client can't have more scopes than the token that creates it. Deleting a client revokes it, and its last token expires within 15 minutes. A secret can't be shown again, so if you lose one, create a new client.

  3. Add houses and radars

    A house is a home or any other place with radars in it. Zoiva generates every id. Names and metadata are yours.

    # A house, with whatever you want to keep alongside it as metadata (up to 20 text fields).
    # The Idempotency-Key makes a retry safe: the same key never creates a second house.
    curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
         -H "Idempotency-Key: $(uuidgen)" \
         -d '{
               "name": "Flat 3, Rose Court",
               "metadata": {
                 "resident_ref": "P-10423",
                 "resident_name": "Jane Smith",
                 "address": "3 Rose Court, London N1 2AB",
                 "care_plan": "CP-1042"
               }
             }' "$API_URL/v1/houses"
    # {"house_id": "goku-0a1b2c3d4e", ...}
    
    # Add a radar by the serial number on its label. Its metadata describes the installation.
    curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
         -d '{"serial_number": "ZR-2026-0017", "room": "bedroom", "location": "above the bed",
              "metadata": {"mounting": "wall", "height_m": "1.8"}}' \
         "$API_URL/v1/houses/goku-0a1b2c3d4e/devices"
    # {"device_id": "goku-3f9a1c7b2e", "status": "claimed", ...}

    Personal details are yours to add, and only if you need them. Zoiva's radars never collect names, addresses or anything that identifies a person. The only personal details Zoiva holds are the ones you put in names and metadata, like resident_name and address above. Often a reference to your own records, like resident_ref, is enough. Put personal details on the house, not the radar: a radar's metadata is copied into every data.summary it sends, while a house's stays in the API.

    Switch the radar on. It connects by itself, its status becomes provisioned and you get a device.provisioned event. If Zoiva added your radars for you, list them with GET /v1/houses/{house_id}/devices.

  4. Create a webhook

    Tell Zoiva where to send your data. Your endpoint must be HTTPS.

    curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
         -d '{"url": "https://hooks.example.com/zoiva", "event_types": ["data.summary", "device.offline", "device.online"]}' \
         "$API_URL/v1/webhooks"
    # {"webhook_id": "wh-4c1d9e0a7b", "secret": "whsec_...", ...}   the secret is shown only now
    
    # send yourself a test event
    curl -s -X POST -H "Authorization: Bearer $TOKEN" "$API_URL/v1/webhooks/wh-4c1d9e0a7b/test"
  5. Receive your first summary

    Within about ten minutes of a radar connecting, its first data.summary arrives at your webhook. Check its signature (receiver examples), store it, and you're done.

GuideIntegrate

An integration is a webhook receiver plus a little housekeeping:

  1. Receive. An HTTPS endpoint checks the signature, stores the event and answers 200 within 5 seconds.
  2. Process. Handle stored events in the background, skipping any event_id you've already seen.
  3. Catch up. After an outage on your side, fetch what you missed from GET /v1/summaries.

A webhook receiver

Each receiver below checks the signature and the timestamp, then stores the event. Read the raw body before parsing it, because the signature covers the exact bytes Zoiva sent.

# pip install fastapi uvicorn. Run: uvicorn receiver:app --port 8000 (behind your HTTPS proxy)
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

SECRET = os.environ["ZOIVA_WEBHOOK_SECRET"].encode()
app = FastAPI()

def is_from_zoiva(headers, body: bytes) -> bool:
    timestamp, signature = headers.get("Zoiva-Timestamp", ""), headers.get("Zoiva-Signature", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) >= 300:
        return False
    expected = "v1=" + hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)

@app.post("/zoiva")
async def receive(request: Request):
    body = await request.body()  # the raw bytes, before any parsing
    if not is_from_zoiva(request.headers, body):
        raise HTTPException(status_code=401)
    event = json.loads(body)
    save_if_new(event["event_id"], event)  # your storage: ignore an event_id you already have
    return {"ok": True}
// npm install express. Run behind your HTTPS proxy.
const crypto = require("node:crypto");
const express = require("express");

const SECRET = process.env.ZOIVA_WEBHOOK_SECRET;
const app = express();

function isFromZoiva(headers, body) {
  const timestamp = headers["zoiva-timestamp"] ?? "";
  if (!/^[0-9]+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) >= 300) return false;
  const hmac = crypto.createHmac("sha256", SECRET).update(`${timestamp}.`).update(body).digest("hex");
  const expected = Buffer.from(`v1=${hmac}`);
  const signature = Buffer.from(headers["zoiva-signature"] ?? "");
  return signature.length === expected.length && crypto.timingSafeEqual(signature, expected);
}

// express.raw keeps the body as the exact bytes Zoiva sent
app.post("/zoiva", express.raw({ type: "application/json" }), (req, res) => {
  if (!isFromZoiva(req.headers, req.body)) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  saveIfNew(event.event_id, event); // your storage: ignore an event_id you already have
  res.json({ ok: true });
});

app.listen(8000);
// ASP.NET Core (.NET 8): dotnet new web, then this as Program.cs. Run behind your HTTPS proxy.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("ZOIVA_WEBHOOK_SECRET")!);
var app = WebApplication.Create(args);

app.MapPost("/zoiva", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var body = buffer.ToArray(); // the raw bytes, before any parsing
    if (!IsFromZoiva(request.Headers, body)) return Results.Unauthorized();
    var zoivaEvent = JsonDocument.Parse(body).RootElement;
    SaveIfNew(zoivaEvent.GetProperty("event_id").GetString()!, zoivaEvent); // your storage: ignore repeats
    return Results.Ok(new { ok = true });
});
app.Run();

bool IsFromZoiva(IHeaderDictionary headers, byte[] body)
{
    string timestamp = headers["Zoiva-Timestamp"].ToString(), signature = headers["Zoiva-Signature"].ToString();
    if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) >= 300)
        return false;
    var signed = Encoding.UTF8.GetBytes(timestamp + ".").Concat(body).ToArray();
    var expected = "v1=" + Convert.ToHexString(HMACSHA256.HashData(secret, signed)).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(signature), Encoding.UTF8.GetBytes(expected));
}
// Spring Boot (spring-boot-starter-web), Java 17 or later. Run behind your HTTPS proxy.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
public class ZoivaWebhook {
    private static final byte[] SECRET = System.getenv("ZOIVA_WEBHOOK_SECRET").getBytes(StandardCharsets.UTF_8);

    @PostMapping("/zoiva")
    public ResponseEntity<String> receive(
            @RequestHeader(value = "Zoiva-Timestamp", defaultValue = "") String timestamp,
            @RequestHeader(value = "Zoiva-Signature", defaultValue = "") String signature,
            @RequestBody byte[] body) throws Exception { // the raw bytes, before any parsing
        if (!isFromZoiva(timestamp, signature, body)) return ResponseEntity.status(401).build();
        saveIfNew(body); // your storage: parse it, and ignore an event_id you already have
        return ResponseEntity.ok("{\"ok\": true}");
    }

    static boolean isFromZoiva(String timestamp, String signature, byte[] body) throws Exception {
        if (!timestamp.matches("[0-9]+")) return false;
        if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) >= 300) return false;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(SECRET, "HmacSHA256"));
        mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
        String expected = "v1=" + HexFormat.of().formatHex(mac.doFinal(body));
        return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                signature.getBytes(StandardCharsets.UTF_8));
    }
}
// Go 1.22 or later, standard library only. Run behind your HTTPS proxy.
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

var secret = []byte(os.Getenv("ZOIVA_WEBHOOK_SECRET"))

func isFromZoiva(h http.Header, body []byte) bool {
    timestamp := h.Get("Zoiva-Timestamp")
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || time.Since(time.Unix(ts, 0)).Abs() >= 5*time.Minute {
        return false
    }
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(timestamp + "."))
    mac.Write(body)
    expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(h.Get("Zoiva-Signature")), []byte(expected))
}

func main() {
    http.HandleFunc("POST /zoiva", func(w http.ResponseWriter, r *http.Request) {
        body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // the raw bytes, before any parsing
        if err != nil || !isFromZoiva(r.Header, body) {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        var event map[string]any
        if err := json.Unmarshal(body, &event); err != nil {
            http.Error(w, "bad request", http.StatusBadRequest)
            return
        }
        saveIfNew(event["event_id"].(string), event) // your storage: ignore an event_id you already have
        w.Write([]byte(`{"ok": true}`))
    })
    http.ListenAndServe(":8000", nil)
}
<?php
// Plain PHP, behind your web server's HTTPS
$secret = getenv('ZOIVA_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // the raw bytes, before any parsing
$timestamp = $_SERVER['HTTP_ZOIVA_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_ZOIVA_SIGNATURE'] ?? '';
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);

if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) >= 300 || !hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
save_if_new($event['event_id'], $event); // your storage: ignore an event_id you already have
header('Content-Type: application/json');
echo '{"ok": true}';

Before you go live

  • Your admin client replaced, and each system on its own client with only the scopes it needs
  • Secrets in a secret store, never in code or logs
  • Every signature checked, and anything older than 5 minutes rejected
  • Answers within 5 seconds, with the work done afterwards
  • Duplicates ignored by event_id, and unknown fields ignored, so new ones never break you
  • A catch-up job on GET /v1/summaries, and an alert if summaries stop arriving

GuideWebhooks and events

Zoiva POSTs each event to your URL as JSON, signed with your webhook's secret.

HeaderWhat it holds
Zoiva-Webhook-Id, Zoiva-Event-Id, Zoiva-Event-TypeWhich webhook and which event
Zoiva-TimestampWhen it was signed, in Unix seconds
Zoiva-Signaturev1= followed by the hex HMAC-SHA256 of <timestamp>.<body>, keyed with your secret
  • Answer fast: any 2xx within 5 seconds counts as delivered.
  • Retries: anything else is retried with growing gaps for about 2 hours. Redirects aren't followed.
  • Duplicates: the same event can arrive twice. Ignore an event_id you've already handled.
  • Health: GET /v1/webhooks/{webhook_id} shows consecutive_failures and the last failure.

Choose what a webhook receives with event_types, device_ids and house_ids when you create it. Without filters it receives every event.

EventWhen
data.summary
Every five minutes (or the frequency you set), for each radar that sent data
device.onlinedevice.offline
A radar connects or drops off
device.addeddevice.updateddevice.moveddevice.removed
Your radars change. A bulk change sends one device.updated per radar.
device.provisioneddevice.reprovision_allowed
A radar connects for the first time, or may connect afresh
device.provisioning_rejected
A radar was refused, for example because its serial isn't in one of your houses
presence_changed
Someone arrives or leaves (details.state, details.range_m)
webhook.test
You asked for a test

GuideSummaries

Every five minutes, each radar that sent data produces one data.summary event, a few minutes after the window ends. It holds the radar's window, the radar itself (device, with your metadata), coverage, presence, distance (range_m), activity, gait, sleep and minute-by-minute vitals. Its event_id is the same every time it's sent. A full example is under GET /v1/summaries.

Every field of data.summary, defined
FieldTypeWhat it means
schema_versionstringThe summary format's version, e.g. 0.1
message_typestringAlways data_summary
event_typestringAlways data.summary
event_idstringsummary-<device_id>-<window start>. The same every time it's sent, so repeats can be ignored
org_idstringYour organisation's id
device_idstringThe radar the summary is about
house_idstringThe house the radar was in, null if it was in none
windowobjectThe five minutes summarised
window.startstringWhen the window starts (ISO 8601, UTC, on a 5-minute boundary)
window.endstringWhen it ends, 5 minutes later
window.secondsintegerIts length in seconds: 300
generated_atstringWhen Zoiva made the summary (ISO 8601, UTC)
deviceobjectThe radar, as registered when the window was summarised
device.namestringYour name for the radar
device.serial_numberstringThe serial number on the radar's label
device.roomstringThe room you gave it, e.g. bedroom
device.locationstringWhere in the room, as you described it
device.metadata.<name>stringYour metadata for this radar, as it was then
device.devices_in_houseintegerHow many radars were in its house, itself included
device.other_device_idsarrayThe other radars in its house
coverageobjectHow much data the window is based on
coverage.seconds_receivedintegerSeconds of data received, out of 300
coverage.valid_secondsintegerSeconds the radar marked as a valid measurement
coverage.completenessnumberseconds_received divided by 300, from 0 to 1
coverage.data_completeness_pct_meannumberOn average, the percentage of the radar's frames that each second was made from
coverage.quality_meannumberAverage signal quality, from 0 to 1. null when the radar doesn't report it
presenceobjectWhether someone was there, and when
presence.present_secondsintegerSeconds in which someone was detected
presence.presence_rationumberpresent_seconds divided by seconds_received, 0 to 1
presence.changesintegerHow many times presence started or stopped
presence.first_present_atstringThe first moment someone was detected (ISO 8601)
presence.last_present_atstringThe last moment someone was detected (ISO 8601)
presence.target_count_maxintegerThe most people detected at the same time
presence.target_count_meannumberThe average number of people detected
range_mobjectHow far from the radar the person was, while present
range_m.secondsintegerSeconds with a distance, that is, while someone was present
range_m.minnumberNearest distance from the radar, in metres
range_m.maxnumberFurthest distance from the radar, in metres
range_m.meannumberAverage distance, in metres
range_m.mediannumberMedian distance, in metres
range_m.stddevnumberHow much the distance varied (standard deviation), in metres
activityobjectMoving and still
activity.moving_secondsintegerSeconds in which the person was moving
activity.stationary_secondsintegerSeconds in which the person was still
gaitobjectWalking
gait.walking_secondsintegerSeconds in which walking was detected
gait.cadence_spm_meannumberAverage cadence while walking, in steps per minute
gait.walking_speed_mps_meannumberAverage walking speed, in metres per second
gait.walking_speed_mps_maxnumberFastest walking speed, in metres per second
sleepobjectSleep states
sleep.state_seconds.<name>integerSeconds in each sleep state the radar reported, by state name
sleep.last_statestringThe latest sleep state in the window
vitalsobjectHeart and breathing rate, minute by minute
vitals.interval_secondsintegerLength of each entry in minutes: 60 seconds
vitals.minuteslistFive entries, one for each minute of the window
vitals.minutes[].startstringThe minute's start (ISO 8601, UTC)
vitals.minutes[].heart_rate_bpmobjectHeart rate, in beats per minute
vitals.minutes[].heart_rate_bpm.meannumberAverage over the minute. null when there were no readings
vitals.minutes[].heart_rate_bpm.minnumberLowest reading in the minute
vitals.minutes[].heart_rate_bpm.maxnumberHighest reading in the minute
vitals.minutes[].heart_rate_bpm.readingsintegerHow many one-second readings there were, leaving out those below min_confidence
vitals.minutes[].breathing_rate_bpmobjectBreathing rate, in breaths per minute
vitals.minutes[].breathing_rate_bpm.meannumberAverage over the minute. null when there were no readings
vitals.minutes[].breathing_rate_bpm.minnumberLowest reading in the minute
vitals.minutes[].breathing_rate_bpm.maxnumberHighest reading in the minute
vitals.minutes[].breathing_rate_bpm.readingsintegerHow many one-second readings there were, leaving out those below min_confidence
featuresobjectOther model outputs over the window, by the model output's name
features.<name>.countintegerHow many readings the model gave in the window
features.<name>.meannumberAverage value
features.<name>.minnumberLowest value
features.<name>.maxnumberHighest value
features.<name>.last_textstringThe latest text value, for models that give labels
features.<name>.unitstringThe values' unit, when the model gives one
settingsobjectHow the summary was made
settings.min_confidencenumberReadings with a confidence below this (0 to 1) were left out of the summary

Fields may be added over time. Ignore the ones you don't know.

How often they arrive

Every five minutes by default, or less often: 5 to 60 minutes, in steps of 5. At the end of each period you receive its windows together, each as its own event. Other events always arrive straight away.

Set onHow
A radarPATCH /v1/devices/{device_id} with {"summary_frequency_minutes": 15}
A housePATCH /v1/houses/{house_id} with {"summary_frequency_minutes": 15}, for its radars without their own setting
A webhookPOST /v1/webhooks/{webhook_id}/frequency with {"frequency_minutes": 15}, for radars with neither

Send null to clear a radar's or a house's setting.

Missed some?

Every summary sent is kept for 30 days. Fetch them by time, by radar or by house.

curl -s -H "Authorization: Bearer $TOKEN" \
  "$API_URL/v1/summaries?house_id=goku-0a1b2c3d4e&start=2026-10-27T00:00:00Z&end=2026-10-28T00:00:00Z"

GuideSecond-level data

Summaries are what's pushed to you today. Everything a radar measures each second is available on request, for one radar (/v1/devices/{device_id}/...) or a whole house (/v1/houses/{house_id}/...).

RouteServes
/recentThe last 3 days, second by second, newest first. The quickest way to near-live data.
/samplesThe full history, one type at a time, up to 24 hours per request
POST /v1/queriesYour own read-only SQL over your data. GET /v1/tables lists the tables.
# the last 10 minutes of a house
curl -s -H "Authorization: Bearer $TOKEN" "$API_URL/v1/houses/goku-0a1b2c3d4e/recent?minutes=10"
# one part of every second: presence, vitals, activity, gait, sleep, quality or features
curl -s -H "Authorization: Bearer $TOKEN" \
  "$API_URL/v1/devices/goku-3f9a1c7b2e/samples?type=vitals&start=2026-10-27T22:00:00Z&end=2026-10-28T06:00:00Z"
# filter on day (yyyy-MM-dd, UTC) to keep queries fast
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
     -d "{\"sql\": \"SELECT device_id, count(*) FROM samples_presence WHERE day = '2026-10-27' GROUP BY 1\"}" \
     "$API_URL/v1/queries"
  • For a near-live view, poll /recent with since set to the last received_at you saw, so you only get what's new.
  • A slow /samples or query answers 202 with a query_id. Fetch the result from GET /v1/queries/{query_id} a little later.
  • A house's data is what its radars sent while they were in it.

GuideConventions and errors

Times
ISO 8601 in UTC, for example 2026-10-06T11:18:25.120Z. Send them ending in Z.
Request bodies
JSON with Content-Type: application/json. Unknown fields are refused, so a typo never goes unnoticed.
Lists
{"items": [...], "next_token": ...}. Pass next_token back for the next page. It's null on the last one.
Retries
GET, PATCH and DELETE can be repeated safely. Creating a house takes an Idempotency-Key. Adding a radar twice returns the same radar.
Scopes
Named zoiva/<name>. Each route in the reference shows the one it needs.

Every error has the same shape. Quote its request_id when you contact Zoiva.

{"error": {"code": "not_found", "message": "radar 'goku-3f9a1c7b2e' not found", "request_id": "..."}}
StatusMeaning
400The request isn't valid. The message says why.
401No token, or it has expired. Get a new one.
403Your client doesn't have the scope this route needs.
404Not found, or not yours.
409A conflict, for example a serial already in another house (serial_in_use).
422An Idempotency-Key reused for a different request.
429Too many requests. Wait a moment and try again.
5xxSomething went wrong on Zoiva's side. Try again later.

GuideLimits

If one gets in your way, talk to us: most can be raised for your organisation.

WhatLimit
Requests20 a second on average, bursts up to 40. Over that you get 429.
Access tokensValid for 15 minutes. Reuse one rather than asking for a token per request.
API clients and webhooksUp to 3 clients and 10 webhooks
Bulk changesUp to 100 radars in one request
MetadataUp to 20 fields per house or radar. Names up to 64 characters, values up to 256.
NamesHouses up to 200 characters, radars and locations up to 100
Idempotency keysRemembered for 24 hours
PagesUp to 100 houses, 500 summaries or /recent batches, and 1,000 rows of samples or query results
QueriesUp to 10 GiB read per query, and 5 running at once
DataKept forPer request
Second-level data, /recent3 daysUp to 3 days back
Summaries, /summaries30 daysUp to 31 days
Second-level data, /samples and /queriesAs long as your agreement with ZoivaUp to 24 hours for /samples

GuideData and privacy

  • No cameras or microphones. The radars sense movement with radio waves. They record no images or sound, and nothing that names a person.
  • Kept apart. Your organisation's data is separate from every other organisation's.
  • Encrypted. In transit over HTTPS only, and at rest.
  • Access you control. Only your API clients can read your data, each limited to its scopes. You create and revoke them.

Personal details are your responsibility. Zoiva only holds the personal details you put in names and metadata (step 3). Add only what you need, and remember that a radar's metadata travels in its summaries. To have data deleted, or for any data protection question, contact Zoiva.

API referenceEvery route

Each route shows the scope it needs. Open a route's parameters, request and response for the details and a full example.

RouteWhat it does
GET/v1/housesList houses
POST/v1/housesCreate a house
GET/v1/houses/{house_id}Get a house
PATCH/v1/houses/{house_id}Change a house's name, metadata or summary frequency
DELETE/v1/houses/{house_id}Delete an empty house
GET/v1/houses/{house_id}/devicesList the radars in a house
POST/v1/houses/{house_id}/devicesAdd a radar to a house
PATCH/v1/houses/{house_id}/devicesChange every radar in a house
PATCH/v1/devicesChange many radars at once
GET/v1/devices/{device_id}Get a radar
PATCH/v1/devices/{device_id}Change a radar's details or summary frequency, or move it to another house
DELETE/v1/devices/{device_id}Remove a radar
POST/v1/devices/{device_id}/reprovisionLet a radar provision again
GET/v1/devices/{device_id}/recentA radar's recent second-level samples (last 3 days)
GET/v1/houses/{house_id}/recentA house's recent second-level samples (last 3 days), every radar
GET/v1/summariesFive-minute summaries already sent (to backfill)
GET/v1/devices/{device_id}/samplesA radar's second-level samples, full history
GET/v1/houses/{house_id}/samplesA house's second-level samples, every radar, full history
GET/v1/tablesYour tables and their columns
POST/v1/queriesRun your own read-only SQL over your data
GET/v1/queries/{query_id}The result of a data request that was still running
GET/v1/webhooksList webhooks
POST/v1/webhooksCreate a webhook
GET/v1/webhooks/{webhook_id}Get a webhook, with its delivery health
DELETE/v1/webhooks/{webhook_id}Delete a webhook
POST/v1/webhooks/{webhook_id}/rotate-secretReplace the signing secret
POST/v1/webhooks/{webhook_id}/testSend a test event
POST/v1/webhooks/{webhook_id}/frequencyUpdate the delivery frequency of a webhook
GET/v1/clientsList your API clients
POST/v1/clientsCreate an API client
GET/v1/clients/{client_id}Get an API client
DELETE/v1/clients/{client_id}Delete an API client
GET/v1/healthIs the API up?

Houses

A house is a home or any other place with radars in it. Create a house, then add radars to it by serial number. A house's data is what its radars sent while they were in it.

List houses

GET/v1/houseszoiva/houses.read
Parameters 2
NameInTypeDescription
limitqueryintegerDefault 50.
next_tokenquerystring
Response 200
{
  "items": [
    {
      "created_at": "2026-10-01T09:12:44.120Z",
      "device_count": 2,
      "house_id": "goku-0a1b2c3d4e",
      "metadata": {
        "care_plan": "CP-1042"
      },
      "name": "Flat 3, Rose Court",
      "updated_at": "2026-10-01T09:12:44.120Z"
    }
  ],
  "next_token": null
}

Create a house

POST/v1/houseszoiva/houses.write

Zoiva generates the house_id (<org>-<10 hex>). The name is yours. Send an Idempotency-Key header so a retried request returns the same house instead of creating a second one.

Parameters 1
NameInTypeDescription
Idempotency-KeyheaderstringA unique value per create (e.g. a UUID): retries with the same key are safe
Request body 2
FieldTypeDescription
namestringYour name for the house, e.g. 'Flat 3, Rose Court'
metadataobjectUp to 20 string key/value pairs of your own
{
  "metadata": {
    "care_plan": "CP-1042"
  },
  "name": "Flat 3, Rose Court"
}
Response 201
{
  "created_at": "2026-10-01T09:12:44.120Z",
  "device_count": 2,
  "house_id": "goku-0a1b2c3d4e",
  "metadata": {
    "care_plan": "CP-1042"
  },
  "name": "Flat 3, Rose Court",
  "updated_at": "2026-10-01T09:12:44.120Z"
}

Get a house

GET/v1/houses/{house_id}zoiva/houses.read
Parameters 1
NameInTypeDescription
house_idrequiredpathstringHouse id (<org>-<10 hex>)
Response 200
{
  "created_at": "2026-10-01T09:12:44.120Z",
  "device_count": 2,
  "house_id": "goku-0a1b2c3d4e",
  "metadata": {
    "care_plan": "CP-1042"
  },
  "name": "Flat 3, Rose Court",
  "updated_at": "2026-10-01T09:12:44.120Z"
}

Change a house's name, metadata or summary frequency

PATCH/v1/houses/{house_id}zoiva/houses.write

Only the fields you send change. summary_frequency_minutes applies to every radar in the house (new ones too) without a frequency of its own. null clears it.

Parameters 1
NameInTypeDescription
house_idrequiredpathstringHouse id (<org>-<10 hex>)
Request body 3
FieldTypeDescription
namestring
metadataobject
summary_frequency_minutesintegerHow often this house's radars' data.summary reach your webhooks (5 to 60 minutes, in steps of 5). It covers every radar in the house, new ones too. A radar's own setting overrides it. null goes back to each webhook's frequency
{
  "name": "Flat 3a, Rose Court"
}
Response 200
{
  "created_at": "2026-10-01T09:12:44.120Z",
  "device_count": 2,
  "house_id": "goku-0a1b2c3d4e",
  "metadata": {
    "care_plan": "CP-1042"
  },
  "name": "Flat 3, Rose Court",
  "updated_at": "2026-10-01T09:12:44.120Z"
}

Delete an empty house

DELETE/v1/houses/{house_id}zoiva/houses.write
Parameters 1
NameInTypeDescription
house_idrequiredpathstringHouse id (<org>-<10 hex>)
Response 204 No content

List the radars in a house

GET/v1/houses/{house_id}/deviceszoiva/devices.read
Parameters 1
NameInTypeDescription
house_idrequiredpathstringHouse id (<org>-<10 hex>)
Response 200
{
  "items": [
    {
      "added_at": "2026-10-01T09:12:44.120Z",
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "location": "above the bed",
      "name": "Bedroom radar",
      "provisioned_at": "2026-10-01T10:02:17.903Z",
      "room": "bedroom",
      "serial_number": "ZR-2026-0017",
      "status": "provisioned"
    }
  ],
  "next_token": null
}

Add a radar to a house

POST/v1/houses/{house_id}/deviceszoiva/devices.write

Add a radar by the serial number on its label. Zoiva generates its device_id (<org>-<10 hex>), which is used in all its data. The name is yours. Switch the radar on and it connects by itself, because its serial is in one of your houses. You then get a device.provisioned event. Adding the same serial to the same house again returns the existing radar (200), so retries are safe. A serial already in another house is refused (409).

Parameters 1
NameInTypeDescription
house_idrequiredpathstringHouse id (<org>-<10 hex>)
Request body 5
FieldTypeDescription
serial_numberrequiredstringThe radar's serial number, printed on its label (unique in your org)
namestringYour name for the radar, e.g. 'Bedroom radar'
roomrequiredstring
locationstringWhere in the room, e.g. 'above the bed'
metadataobjectUp to 20 string key/value pairs of your own about the radar's deployment, e.g. {'mounting': 'wall', 'height_m': '1.8', 'deployed_with': 'bed sensor'}. Sent in every data.summary.
{
  "location": "above the bed",
  "name": "Bedroom radar",
  "room": "bedroom",
  "serial_number": "ZR-2026-0017"
}
Response 201
{
  "added_at": "2026-10-01T09:12:44.120Z",
  "device_id": "goku-3f9a1c7b2e",
  "house_id": "goku-0a1b2c3d4e",
  "location": "above the bed",
  "name": "Bedroom radar",
  "provisioned_at": "2026-10-01T10:02:17.903Z",
  "room": "bedroom",
  "serial_number": "ZR-2026-0017",
  "status": "provisioned"
}

Change every radar in a house

PATCH/v1/houses/{house_id}/deviceszoiva/devices.write

The same room, location, metadata or summary_frequency_minutes for each radar in the house, as PATCH /v1/devices does for a list of radars. To set a frequency for the house itself (new radars too), use PATCH /v1/houses/{house_id}.

Parameters 1
NameInTypeDescription
house_idrequiredpathstringHouse id (<org>-<10 hex>)
Request body 4
FieldTypeDescription
roomstring
locationstring
metadataobjectReplaces each radar's metadata (all of it)
summary_frequency_minutesintegerAs on PATCH /v1/devices/{device_id}. null goes back to the house's frequency
{
  "location": "by the door",
  "summary_frequency_minutes": 15
}
Response 200
{
  "failed": [
    {
      "device_id": "goku-7c1e9a0b3d",
      "error": {
        "code": "not_found",
        "message": "radar 'goku-7c1e9a0b3d' not found"
      }
    }
  ],
  "updated": [
    {
      "added_at": "2026-10-01T09:12:44.120Z",
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "location": "above the bed",
      "metadata": {},
      "name": "Bedroom radar",
      "provisioned_at": "2026-10-01T10:02:17.903Z",
      "room": "bedroom",
      "serial_number": "ZR-2026-0017",
      "status": "provisioned",
      "summary_frequency_minutes": 15
    }
  ]
}

Radars

Your radars. A radar is added to a house by the serial number on its label (status claimed), and becomes provisioned once it has connected. Move it by changing its house_id. Removing it disconnects it at once. Change up to 100 radars at once with PATCH /v1/devices.

Change many radars at once

PATCH/v1/deviceszoiva/devices.write

The same room, location, metadata or summary_frequency_minutes for up to 100 radars. Each radar is updated on its own: one that fails (not found, changed at the same time) is listed in failed and the others still change. Moving radars between houses is one radar at a time.

Request body 2
FieldTypeDescription
device_idsrequiredarrayUp to 100 radars, each once
changesrequiredobjectWhat a bulk update may change on each radar. Moving radars between houses is one radar at a time (PATCH /v1/devices/{device_id}).
{
  "changes": {
    "summary_frequency_minutes": 15
  },
  "device_ids": [
    "goku-3f9a1c7b2e",
    "goku-7c1e9a0b3d"
  ]
}
Response 200
{
  "failed": [
    {
      "device_id": "goku-7c1e9a0b3d",
      "error": {
        "code": "not_found",
        "message": "radar 'goku-7c1e9a0b3d' not found"
      }
    }
  ],
  "updated": [
    {
      "added_at": "2026-10-01T09:12:44.120Z",
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "location": "above the bed",
      "metadata": {},
      "name": "Bedroom radar",
      "provisioned_at": "2026-10-01T10:02:17.903Z",
      "room": "bedroom",
      "serial_number": "ZR-2026-0017",
      "status": "provisioned",
      "summary_frequency_minutes": 15
    }
  ]
}

Get a radar

GET/v1/devices/{device_id}zoiva/devices.read
Parameters 1
NameInTypeDescription
device_idrequiredpathstringDevice id (<org>-<10 hex>)
Response 200
{
  "added_at": "2026-10-01T09:12:44.120Z",
  "device_id": "goku-3f9a1c7b2e",
  "house_id": "goku-0a1b2c3d4e",
  "location": "above the bed",
  "name": "Bedroom radar",
  "provisioned_at": "2026-10-01T10:02:17.903Z",
  "room": "bedroom",
  "serial_number": "ZR-2026-0017",
  "status": "provisioned"
}

Change a radar's details or summary frequency, or move it to another house

PATCH/v1/devices/{device_id}zoiva/devices.write

Only the fields you send change. metadata is replaced as a whole. The next data.summary describes the radar as it is then.

Parameters 1
NameInTypeDescription
device_idrequiredpathstringDevice id (<org>-<10 hex>)
Request body 6
FieldTypeDescription
house_idstringMove the radar to another house
namestring
roomstring
locationstring
metadataobjectReplaces the radar's metadata (all of it)
summary_frequency_minutesintegerHow often this radar's data.summary reach your webhooks (5 to 60 minutes, in steps of 5), overriding its house's. null: back to its house's (or each webhook's)
{
  "house_id": "goku-7b2e9f1c3a",
  "room": "living_room"
}
Response 200
{
  "added_at": "2026-10-01T09:12:44.120Z",
  "device_id": "goku-3f9a1c7b2e",
  "house_id": "goku-0a1b2c3d4e",
  "location": "above the bed",
  "name": "Bedroom radar",
  "provisioned_at": "2026-10-01T10:02:17.903Z",
  "room": "bedroom",
  "serial_number": "ZR-2026-0017",
  "status": "provisioned"
}

Remove a radar

DELETE/v1/devices/{device_id}zoiva/devices.write

The radar can no longer connect until it is added to a house again.

Parameters 1
NameInTypeDescription
device_idrequiredpathstringDevice id (<org>-<10 hex>)
Response 204 No content

Let a radar provision again

POST/v1/devices/{device_id}/reprovisionzoiva/devices.write

For a radar that needs to connect afresh, e.g. after it was reset or repaired. Once provisioned, a radar can't provision again, so nobody else can take over its identity. This opens a 24-hour window for it. Switch the radar on within the window. Its old certificate stops working as soon as it provisions.

Parameters 1
NameInTypeDescription
device_idrequiredpathstringDevice id (<org>-<10 hex>)
Response 200
{
  "added_at": "2026-10-01T09:12:44.120Z",
  "device_id": "goku-3f9a1c7b2e",
  "house_id": "goku-0a1b2c3d4e",
  "location": "above the bed",
  "name": "Bedroom radar",
  "provisioned_at": "2026-10-01T10:02:17.903Z",
  "room": "bedroom",
  "serial_number": "ZR-2026-0017",
  "status": "provisioned"
}

Data

What your radars measure. Every 5 minutes, each radar's data.summary reaches your webhooks, and /summaries serves the ones already sent (30 days). /recent serves the last 3 days second by second, /samples every second ever recorded, and /queries runs your own SQL over your data. /recent and /samples work for one radar (/devices/{device_id}/...) or a whole house (/houses/{house_id}/...). Times are ISO 8601, UTC.

A radar's recent second-level samples (last 3 days)

GET/v1/devices/{device_id}/recentzoiva/data.read

Newest first, a few seconds per batch. Pass next_token back for older batches. Times are ISO 8601 in UTC ending in Z, e.g. 2026-10-06T11:18:25Z.

Parameters 6
NameInTypeDescription
device_idrequiredpathstringDevice id (<org>-<10 hex>)
minutesqueryintegerHow far back to look (ignored if since is set) Default 10.
sincequerystring (date-time)Start (ISO 8601, UTC). Default: until, minus minutes
untilquerystring (date-time)End (ISO 8601, UTC). Default: now
limitqueryintegerSample batches per page (one batch is a few seconds) Default 100.
next_tokenquerystring
Response 200
{
  "items": [
    {
      "context_id": "ctx-1a2b3c4d5e6f",
      "device_id": "goku-3f9a1c7b2e",
      "message": {
        "context_id": "ctx-1a2b3c4d5e6f",
        "message_type": "feature_batch",
        "samples": [
          {
            "activity": {
              "state": "stationary"
            },
            "captured_at": "2026-10-06T11:18:21.000Z",
            "presence": {
              "primary_target": {
                "range": 1.84
              },
              "state": "present",
              "target_count": 1
            },
            "quality": {
              "data_completeness": {
                "percentage": 100.0
              }
            },
            "seq": 1204,
            "status": {
              "measurement_status": "valid"
            },
            "vitals": {
              "available": false
            }
          }
        ],
        "schema_version": "2.2"
      },
      "received_at": "2026-10-06T11:18:25.120Z"
    }
  ],
  "next_token": null
}

A house's recent second-level samples (last 3 days), every radar

GET/v1/houses/{house_id}/recentzoiva/data.read

Every radar now in the house, newest first. Each batch says which radar sent it (device_id). Pass next_token back for older batches.

Parameters 6
NameInTypeDescription
house_idrequiredpathstringHouse id (<org>-<10 hex>)
minutesqueryintegerHow far back to look (ignored if since is set) Default 10.
sincequerystring (date-time)Start (ISO 8601, UTC). Default: until, minus minutes
untilquerystring (date-time)End (ISO 8601, UTC). Default: now
limitqueryintegerSample batches per page, across all radars Default 100.
next_tokenquerystring
Response 200
{
  "items": [
    {
      "context_id": "ctx-1a2b3c4d5e6f",
      "device_id": "goku-3f9a1c7b2e",
      "message": {
        "context_id": "ctx-1a2b3c4d5e6f",
        "message_type": "feature_batch",
        "samples": [
          {
            "activity": {
              "state": "stationary"
            },
            "captured_at": "2026-10-06T11:18:21.000Z",
            "presence": {
              "primary_target": {
                "range": 1.84
              },
              "state": "present",
              "target_count": 1
            },
            "quality": {
              "data_completeness": {
                "percentage": 100.0
              }
            },
            "seq": 1204,
            "status": {
              "measurement_status": "valid"
            },
            "vitals": {
              "available": false
            }
          }
        ],
        "schema_version": "2.2"
      },
      "received_at": "2026-10-06T11:18:25.120Z"
    }
  ],
  "next_token": null
}

Five-minute summaries already sent (to backfill)

GET/v1/summarieszoiva/data.read

The data.summary events sent for windows starting between start and end, exactly as they were delivered, oldest first. Ask for every radar, one radar (device_id) or one house (house_id). Use it after your webhook endpoint was down, and skip the event_ids you already have. Summaries are kept for 30 days. Ask Zoiva for older ones.

Parameters 6
NameInTypeDescription
startrequiredquerystring (date-time)Windows starting from this time (ISO 8601)
endrequiredquerystring (date-time)Up to this time, not included (ISO 8601). At most 31 days after start
device_idquerystringOnly this radar
house_idquerystringOnly radars while in this house
limitqueryintegerDefault 100.
next_tokenquerystring
Response 200
{
  "items": [
    {
      "activity": {
        "moving_seconds": 41,
        "stationary_seconds": 221
      },
      "coverage": {
        "completeness": 1.0,
        "data_completeness_pct_mean": 99.4,
        "seconds_received": 300,
        "valid_seconds": 297
      },
      "device": {
        "devices_in_house": 2,
        "location": "above the bed",
        "metadata": {
          "deployed_with": "bed sensor",
          "height_m": "1.8",
          "mounting": "wall"
        },
        "name": "Bedroom radar",
        "other_device_ids": [
          "goku-7c1e9a0b3d"
        ],
        "room": "bedroom",
        "serial_number": "8e2f1c0a5b6d7e90"
      },
      "device_id": "goku-3f9a1c7b2e",
      "event_id": "summary-goku-3f9a1c7b2e-2026-11-03T14:30:00Z",
      "event_type": "data.summary",
      "features": {},
      "gait": {
        "cadence_spm_mean": 96.4,
        "walking_seconds": 18,
        "walking_speed_mps_max": 0.94,
        "walking_speed_mps_mean": 0.612
      },
      "generated_at": "2026-11-03T14:40:06.312Z",
      "house_id": "goku-0a1b2c3d4e",
      "message_type": "data_summary",
      "org_id": "goku",
      "presence": {
        "changes": 2,
        "first_present_at": "2026-11-03T14:30:04.000Z",
        "last_present_at": "2026-11-03T14:34:59.000Z",
        "presence_ratio": 0.873,
        "present_seconds": 262,
        "target_count_max": 1,
        "target_count_mean": 0.873
      },
      "range_m": {
        "max": 2.41,
        "mean": 1.634,
        "median": 1.52,
        "min": 1.08,
        "seconds": 262,
        "stddev": 0.292
      },
      "schema_version": "0.1",
      "settings": {
        "min_confidence": 0.6
      },
      "sleep": {
        "state_seconds": {}
      },
      "vitals": {
        "interval_seconds": 60,
        "minutes": [
          {
            "breathing_rate_bpm": {
              "max": 17.0,
              "mean": 15.2,
              "min": 14.0,
              "readings": 57
            },
            "heart_rate_bpm": {
              "max": 75.0,
              "mean": 71.4,
              "min": 68.0,
              "readings": 58
            },
            "start": "2026-11-03T14:30:00Z"
          },
          {
            "breathing_rate_bpm": {
              "max": 16.0,
              "mean": 15.0,
              "min": 13.0,
              "readings": 59
            },
            "heart_rate_bpm": {
              "max": 76.0,
              "mean": 72.0,
              "min": 69.0,
              "readings": 59
            },
            "start": "2026-11-03T14:31:00Z"
          },
          {
            "breathing_rate_bpm": {
              "readings": 0
            },
            "heart_rate_bpm": {
              "readings": 0
            },
            "start": "2026-11-03T14:33:00Z"
          }
        ]
      },
      "window": {
        "end": "2026-11-03T14:35:00Z",
        "seconds": 300,
        "start": "2026-11-03T14:30:00Z"
      }
    }
  ],
  "next_token": null
}

A radar's second-level samples, full history

GET/v1/devices/{device_id}/sampleszoiva/data.read

Every second-level sample received in the time range, one type at a time (type: presence, vitals, activity, gait, sleep, quality or features), as the radar sent it (nested fields as JSON objects), oldest first. For the last 3 days, /recent (every type together) is faster.

Parameters 5
NameInTypeDescription
device_idrequiredpathstringDevice id (<org>-<10 hex>)
startrequiredquerystring (date-time)From this time (ISO 8601)
endrequiredquerystring (date-time)Up to this time, not included (ISO 8601). At most 24 hours after start
typerequiredquerystringWhich part of the samples: presence, vitals, activity, gait, sleep, quality (with status, clock and transport) or features (any model's outputs)
limitqueryintegerDefault 500.
Response 200
{
  "query_id": "3b9f2c1e-7a4d-4e8b-9c0f-5d6e7f8a9b0c",
  "rows": [
    {
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "minute": "2026-10-01T10:27:00Z",
      "presence_ratio": 1.0,
      "sample_count": 60,
      "target_count_max": 1
    }
  ],
  "status": "succeeded"
}
Response 202
{
  "query_id": "3b9f2c1e-7a4d-4e8b-9c0f-5d6e7f8a9b0c",
  "rows": [
    {
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "minute": "2026-10-01T10:27:00Z",
      "presence_ratio": 1.0,
      "sample_count": 60,
      "target_count_max": 1
    }
  ],
  "status": "succeeded"
}

A house's second-level samples, every radar, full history

GET/v1/houses/{house_id}/sampleszoiva/data.read

Every second-level sample received in the time range, one type at a time (type: presence, vitals, activity, gait, sleep, quality or features), as the radar sent it (nested fields as JSON objects), oldest first. For the last 3 days, /recent (every type together) is faster.

Parameters 5
NameInTypeDescription
house_idrequiredpathstringHouse id (<org>-<10 hex>)
startrequiredquerystring (date-time)From this time (ISO 8601)
endrequiredquerystring (date-time)Up to this time, not included (ISO 8601). At most 24 hours after start
typerequiredquerystringWhich part of the samples: presence, vitals, activity, gait, sleep, quality (with status, clock and transport) or features (any model's outputs)
limitqueryintegerDefault 500.
Response 200
{
  "query_id": "3b9f2c1e-7a4d-4e8b-9c0f-5d6e7f8a9b0c",
  "rows": [
    {
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "minute": "2026-10-01T10:27:00Z",
      "presence_ratio": 1.0,
      "sample_count": 60,
      "target_count_max": 1
    }
  ],
  "status": "succeeded"
}
Response 202
{
  "query_id": "3b9f2c1e-7a4d-4e8b-9c0f-5d6e7f8a9b0c",
  "rows": [
    {
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "minute": "2026-10-01T10:27:00Z",
      "presence_ratio": 1.0,
      "sample_count": 60,
      "target_count_max": 1
    }
  ],
  "status": "succeeded"
}

Your tables and their columns

GET/v1/tableszoiva/data.read

What POST /v1/queries can read. samples_<type> holds one row per 5-second message, with its seconds in a samples list (CROSS JOIN UNNEST(samples) AS t(s) gives one row per second). events_<type> holds your radars' events. Every table has day (yyyy-MM-dd, UTC, the day Zoiva received the data). Filter on it to keep queries fast.

Response 200
[
  {
    "columns": [
      {
        "name": "device_id",
        "type": "string"
      },
      {
        "name": "house_id",
        "type": "string"
      },
      {
        "name": "received_at",
        "type": "bigint"
      },
      {
        "name": "day",
        "type": "string"
      },
      {
        "name": "samples",
        "type": "array<struct<seq:bigint,captured_at:string,presence:struct<...>>>"
      }
    ],
    "name": "samples_presence"
  }
]

Run your own read-only SQL over your data

POST/v1/querieszoiva/data.read

One SELECT (or WITH ... SELECT) statement over your tables (GET /v1/tables), in Trino SQL. Only your organisation's data is there. A query that would read more than 10 GiB is cancelled (query_too_large), and at most 5 of your queries run at once (429). The first rows come back within 20 seconds. A slower query answers 202 with a query_id: fetch it from GET /v1/queries/{query_id}, and page through rows with next_token.

Parameters 1
NameInTypeDescription
limitqueryintegerRows in the first page Default 500.
Request body 1
FieldTypeDescription
sqlrequiredstringOne read-only statement (SELECT or WITH) over your tables (GET /v1/tables), e.g. SELECT device_id, count(*) FROM samples_presence WHERE day = '2026-10-27' GROUP BY 1. Filter on day (yyyy-MM-dd, UTC, when the data arrived) to read little: a query reading more than your limit is cancelled.
{
  "sql": "SELECT device_id, count(*) FROM samples_presence WHERE day = '2026-10-27' GROUP BY 1"
}
Response 200
{
  "query_id": "3b9f2c1e-7a4d-4e8b-9c0f-5d6e7f8a9b0c",
  "rows": [
    {
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "minute": "2026-10-01T10:27:00Z",
      "presence_ratio": 1.0,
      "sample_count": 60,
      "target_count_max": 1
    }
  ],
  "status": "succeeded"
}
Response 202
{
  "query_id": "3b9f2c1e-7a4d-4e8b-9c0f-5d6e7f8a9b0c",
  "rows": [
    {
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "minute": "2026-10-01T10:27:00Z",
      "presence_ratio": 1.0,
      "sample_count": 60,
      "target_count_max": 1
    }
  ],
  "status": "succeeded"
}

The result of a data request that was still running

GET/v1/queries/{query_id}zoiva/data.read
Parameters 3
NameInTypeDescription
query_idrequiredpathstring
next_tokenquerystring
limitqueryintegerDefault 500.
Response 200
{
  "query_id": "3b9f2c1e-7a4d-4e8b-9c0f-5d6e7f8a9b0c",
  "rows": [
    {
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "minute": "2026-10-01T10:27:00Z",
      "presence_ratio": 1.0,
      "sample_count": 60,
      "target_count_max": 1
    }
  ],
  "status": "succeeded"
}
Response 202
{
  "query_id": "3b9f2c1e-7a4d-4e8b-9c0f-5d6e7f8a9b0c",
  "rows": [
    {
      "device_id": "goku-3f9a1c7b2e",
      "house_id": "goku-0a1b2c3d4e",
      "minute": "2026-10-01T10:27:00Z",
      "presence_ratio": 1.0,
      "sample_count": 60,
      "target_count_max": 1
    }
  ],
  "status": "succeeded"
}

Webhooks

Get events POSTed to your HTTPS endpoint: each radar's data.summary every 5 minutes (or less often: see frequency), and as they happen, radars connecting, dropping off and changing, and presence. Every delivery is signed with the webhook's secret.

List webhooks

GET/v1/webhookszoiva/webhooks.write
Response 200
{
  "items": [
    {
      "consecutive_failures": 0,
      "created_at": "2026-10-02T08:05:00.000Z",
      "description": "Care dashboard",
      "event_types": [
        "data.summary",
        "device.offline"
      ],
      "frequency_minutes": 5,
      "url": "https://hooks.example.com/zoiva",
      "webhook_id": "wh-4c1d9e0a7b"
    }
  ],
  "next_token": null
}

Create a webhook

POST/v1/webhookszoiva/webhooks.write

Returns the webhook with its signing secret, shown only this once. Filters are optional: without any, every event is delivered.

Request body 5
FieldTypeDescription
urlrequiredstringYour HTTPS endpoint, e.g. https://hooks.example.com/zoiva
descriptionstring
event_typesarrayOnly these event types, e.g. ['data.summary', 'device.offline']. Every event if unset
device_idsarrayOnly events of these radars
house_idsarrayOnly events of these houses
{
  "description": "Care dashboard",
  "event_types": [
    "data.summary",
    "device.offline"
  ],
  "url": "https://hooks.example.com/zoiva"
}
Response 201
{
  "consecutive_failures": 0,
  "created_at": "2026-10-02T08:05:00.000Z",
  "description": "Care dashboard",
  "event_types": [
    "data.summary",
    "device.offline"
  ],
  "frequency_minutes": 5,
  "secret": "whsec_<shown only once>",
  "url": "https://hooks.example.com/zoiva",
  "webhook_id": "wh-4c1d9e0a7b"
}

Get a webhook, with its delivery health

GET/v1/webhooks/{webhook_id}zoiva/webhooks.write
Parameters 1
NameInTypeDescription
webhook_idrequiredpathstring
Response 200
{
  "consecutive_failures": 0,
  "created_at": "2026-10-02T08:05:00.000Z",
  "description": "Care dashboard",
  "event_types": [
    "data.summary",
    "device.offline"
  ],
  "frequency_minutes": 5,
  "url": "https://hooks.example.com/zoiva",
  "webhook_id": "wh-4c1d9e0a7b"
}

Delete a webhook

DELETE/v1/webhooks/{webhook_id}zoiva/webhooks.write
Parameters 1
NameInTypeDescription
webhook_idrequiredpathstring
Response 204 No content

Replace the signing secret

POST/v1/webhooks/{webhook_id}/rotate-secretzoiva/webhooks.write

The new secret signs every delivery from now on. The old one stops working at once.

Parameters 1
NameInTypeDescription
webhook_idrequiredpathstring
Response 200
{
  "consecutive_failures": 0,
  "created_at": "2026-10-02T08:05:00.000Z",
  "description": "Care dashboard",
  "event_types": [
    "data.summary",
    "device.offline"
  ],
  "frequency_minutes": 5,
  "secret": "whsec_<shown only once>",
  "url": "https://hooks.example.com/zoiva",
  "webhook_id": "wh-4c1d9e0a7b"
}

Send a test event

POST/v1/webhooks/{webhook_id}/testzoiva/webhooks.write

Delivers a webhook.test event to this webhook within seconds, signed like any other.

Parameters 1
NameInTypeDescription
webhook_idrequiredpathstring
Response 202

JSON

Update the delivery frequency of a webhook

POST/v1/webhooks/{webhook_id}/frequencyzoiva/webhooks.write

How often data.summary events are delivered to this webhook: every 5 to 60 minutes, in steps of 5. Device events are always delivered at once. Takes effect from the next delivery.

Parameters 1
NameInTypeDescription
webhook_idrequiredpathstring
Request body 1
FieldTypeDescription
frequency_minutesintegerHow often Zoiva delivers data.summary events to this webhook (in minutes). Device events (device.*, presence_changed) are always delivered immediately. Default 5.
{
  "frequency_minutes": 15
}
Response 200
{
  "consecutive_failures": 0,
  "created_at": "2026-10-02T08:05:00.000Z",
  "description": "Care dashboard",
  "event_types": [
    "data.summary",
    "device.offline"
  ],
  "frequency_minutes": 5,
  "url": "https://hooks.example.com/zoiva",
  "webhook_id": "wh-4c1d9e0a7b"
}

API clients

Your organisation's credentials: at most 3 clients, one per system, each with only the scopes it needs. Secrets are shown once, when a client is created.

List your API clients

GET/v1/clientszoiva/clients.write
Response 200
{
  "items": [
    {
      "client_id": "4h1k2m3n5p6q7r8s9t0v1w2x3y",
      "created_at": "2026-10-02T08:00:00.000Z",
      "name": "nightly-sync",
      "scopes": [
        "zoiva/houses.read",
        "zoiva/data.read"
      ]
    }
  ],
  "next_token": null
}

Create an API client

POST/v1/clientszoiva/clients.write

Returns its client_secret this once. A client can have at most the scopes of the token creating it.

Request body 2
FieldTypeDescription
namerequiredstringWhat the client is for, e.g. 'nightly-sync' (letters, digits, space . _ -)
scopesrequiredarrayOnly what it needs, e.g. ['zoiva/data.read']. At most your own scopes
{
  "name": "nightly-sync",
  "scopes": [
    "zoiva/houses.read",
    "zoiva/data.read"
  ]
}
Response 201
{
  "client_id": "4h1k2m3n5p6q7r8s9t0v1w2x3y",
  "client_secret": "<shown only once>",
  "created_at": "2026-10-02T08:00:00.000Z",
  "name": "nightly-sync",
  "scopes": [
    "zoiva/houses.read",
    "zoiva/data.read"
  ],
  "token_url": "https://<your-auth-domain>/oauth2/token"
}

Get an API client

GET/v1/clients/{client_id}zoiva/clients.write
Parameters 1
NameInTypeDescription
client_idrequiredpathstring
Response 200
{
  "client_id": "4h1k2m3n5p6q7r8s9t0v1w2x3y",
  "created_at": "2026-10-02T08:00:00.000Z",
  "name": "nightly-sync",
  "scopes": [
    "zoiva/houses.read",
    "zoiva/data.read"
  ]
}

Delete an API client

DELETE/v1/clients/{client_id}zoiva/clients.write

It can't get new tokens from now on. You can't delete your last client that has zoiva/clients.write.

Parameters 1
NameInTypeDescription
client_idrequiredpathstring
Response 204 No content

Health

Is the API up? No token needed.

Is the API up?

GET/v1/healthno token
Response 200

JSON

MoreVersions and support

The version is in the path (/v1). Within /v1, changes only add things, such as new routes and optional fields. Anything incompatible would come as /v2, announced well in advance. This page is updated with each release.

For help, or to ask for a feature, contact your Zoiva representative. Include the request_id of any call that failed.

↑