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.
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
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.
// .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());
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.
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.
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"
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:
Receive. An HTTPS endpoint checks the signature, stores the event and answers 200 within 5 seconds.
Process. Handle stored events in the background, skipping any event_id you've already seen.
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.
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
Field
Type
What it means
schema_version
string
The summary format's version, e.g. 0.1
message_type
string
Always data_summary
event_type
string
Always data.summary
event_id
string
summary-<device_id>-<window start>. The same every time it's sent, so repeats can be ignored
org_id
string
Your organisation's id
device_id
string
The radar the summary is about
house_id
string
The house the radar was in, null if it was in none
window
object
The five minutes summarised
window.start
string
When the window starts (ISO 8601, UTC, on a 5-minute boundary)
window.end
string
When it ends, 5 minutes later
window.seconds
integer
Its length in seconds: 300
generated_at
string
When Zoiva made the summary (ISO 8601, UTC)
device
object
The radar, as registered when the window was summarised
device.name
string
Your name for the radar
device.serial_number
string
The serial number on the radar's label
device.room
string
The room you gave it, e.g. bedroom
device.location
string
Where in the room, as you described it
device.metadata.<name>
string
Your metadata for this radar, as it was then
device.devices_in_house
integer
How many radars were in its house, itself included
device.other_device_ids
array
The other radars in its house
coverage
object
How much data the window is based on
coverage.seconds_received
integer
Seconds of data received, out of 300
coverage.valid_seconds
integer
Seconds the radar marked as a valid measurement
coverage.completeness
number
seconds_received divided by 300, from 0 to 1
coverage.data_completeness_pct_mean
number
On average, the percentage of the radar's frames that each second was made from
coverage.quality_mean
number
Average signal quality, from 0 to 1. null when the radar doesn't report it
presence
object
Whether someone was there, and when
presence.present_seconds
integer
Seconds in which someone was detected
presence.presence_ratio
number
present_seconds divided by seconds_received, 0 to 1
presence.changes
integer
How many times presence started or stopped
presence.first_present_at
string
The first moment someone was detected (ISO 8601)
presence.last_present_at
string
The last moment someone was detected (ISO 8601)
presence.target_count_max
integer
The most people detected at the same time
presence.target_count_mean
number
The average number of people detected
range_m
object
How far from the radar the person was, while present
range_m.seconds
integer
Seconds with a distance, that is, while someone was present
range_m.min
number
Nearest distance from the radar, in metres
range_m.max
number
Furthest distance from the radar, in metres
range_m.mean
number
Average distance, in metres
range_m.median
number
Median distance, in metres
range_m.stddev
number
How much the distance varied (standard deviation), in metres
activity
object
Moving and still
activity.moving_seconds
integer
Seconds in which the person was moving
activity.stationary_seconds
integer
Seconds in which the person was still
gait
object
Walking
gait.walking_seconds
integer
Seconds in which walking was detected
gait.cadence_spm_mean
number
Average cadence while walking, in steps per minute
gait.walking_speed_mps_mean
number
Average walking speed, in metres per second
gait.walking_speed_mps_max
number
Fastest walking speed, in metres per second
sleep
object
Sleep states
sleep.state_seconds.<name>
integer
Seconds in each sleep state the radar reported, by state name
sleep.last_state
string
The latest sleep state in the window
vitals
object
Heart and breathing rate, minute by minute
vitals.interval_seconds
integer
Length of each entry in minutes: 60 seconds
vitals.minutes
list
Five entries, one for each minute of the window
vitals.minutes[].start
string
The minute's start (ISO 8601, UTC)
vitals.minutes[].heart_rate_bpm
object
Heart rate, in beats per minute
vitals.minutes[].heart_rate_bpm.mean
number
Average over the minute. null when there were no readings
vitals.minutes[].heart_rate_bpm.min
number
Lowest reading in the minute
vitals.minutes[].heart_rate_bpm.max
number
Highest reading in the minute
vitals.minutes[].heart_rate_bpm.readings
integer
How many one-second readings there were, leaving out those below min_confidence
vitals.minutes[].breathing_rate_bpm
object
Breathing rate, in breaths per minute
vitals.minutes[].breathing_rate_bpm.mean
number
Average over the minute. null when there were no readings
vitals.minutes[].breathing_rate_bpm.min
number
Lowest reading in the minute
vitals.minutes[].breathing_rate_bpm.max
number
Highest reading in the minute
vitals.minutes[].breathing_rate_bpm.readings
integer
How many one-second readings there were, leaving out those below min_confidence
features
object
Other model outputs over the window, by the model output's name
features.<name>.count
integer
How many readings the model gave in the window
features.<name>.mean
number
Average value
features.<name>.min
number
Lowest value
features.<name>.max
number
Highest value
features.<name>.last_text
string
The latest text value, for models that give labels
features.<name>.unit
string
The values' unit, when the model gives one
settings
object
How the summary was made
settings.min_confidence
number
Readings 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 on
How
A radar
PATCH /v1/devices/{device_id} with {"summary_frequency_minutes": 15}
A house
PATCH /v1/houses/{house_id} with {"summary_frequency_minutes": 15}, for its radars without their own setting
A webhook
POST /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.
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}/...).
Route
Serves
/recent
The last 3 days, second by second, newest first. The quickest way to near-live data.
/samples
The full history, one type at a time, up to 24 hours per request
POST /v1/queries
Your 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": "..."}}
Status
Meaning
400
The request isn't valid. The message says why.
401
No token, or it has expired. Get a new one.
403
Your client doesn't have the scope this route needs.
404
Not found, or not yours.
409
A conflict, for example a serial already in another house (serial_in_use).
422
An Idempotency-Key reused for a different request.
429
Too many requests. Wait a moment and try again.
5xx
Something 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.
What
Limit
Requests
20 a second on average, bursts up to 40. Over that you get 429.
Access tokens
Valid for 15 minutes. Reuse one rather than asking for a token per request.
API clients and webhooks
Up to 3 clients and 10 webhooks
Bulk changes
Up to 100 radars in one request
Metadata
Up to 20 fields per house or radar. Names up to 64 characters, values up to 256.
Names
Houses up to 200 characters, radars and locations up to 100
Idempotency keys
Remembered for 24 hours
Pages
Up to 100 houses, 500 summaries or /recent batches, and 1,000 rows of samples or query results
Queries
Up to 10 GiB read per query, and 5 running at once
Data
Kept for
Per request
Second-level data, /recent
3 days
Up to 3 days back
Summaries, /summaries
30 days
Up to 31 days
Second-level data, /samples and /queries
As long as your agreement with Zoiva
Up 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.
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.
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
Name
In
Type
Description
Idempotency-Key
header
string
A unique value per create (e.g. a UUID): retries with the same key are safe
Request body 2
Field
Type
Description
name
string
Your name for the house, e.g. 'Flat 3, Rose Court'
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
Name
In
Type
Description
house_idrequired
path
string
House id (<org>-<10 hex>)
Request body 3
Field
Type
Description
name
string
metadata
object
summary_frequency_minutes
integer
How 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
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
Name
In
Type
Description
house_idrequired
path
string
House id (<org>-<10 hex>)
Request body 5
Field
Type
Description
serial_numberrequired
string
The radar's serial number, printed on its label (unique in your org)
name
string
Your name for the radar, e.g. 'Bedroom radar'
roomrequired
string
location
string
Where in the room, e.g. 'above the bed'
metadata
object
Up 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.
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
Name
In
Type
Description
house_idrequired
path
string
House id (<org>-<10 hex>)
Request body 4
Field
Type
Description
room
string
location
string
metadata
object
Replaces each radar's metadata (all of it)
summary_frequency_minutes
integer
As on PATCH /v1/devices/{device_id}. null goes back to the house's frequency
{
"location": "by the door",
"summary_frequency_minutes": 15
}
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
Field
Type
Description
device_idsrequired
array
Up to 100 radars, each once
changesrequired
object
What a bulk update may change on each radar. Moving radars between houses is one radar at a time
(PATCH /v1/devices/{device_id}).
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
Name
In
Type
Description
device_idrequired
path
string
Device id (<org>-<10 hex>)
Request body 6
Field
Type
Description
house_id
string
Move the radar to another house
name
string
room
string
location
string
metadata
object
Replaces the radar's metadata (all of it)
summary_frequency_minutes
integer
How 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)
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.
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
Name
In
Type
Description
device_idrequired
path
string
Device id (<org>-<10 hex>)
minutes
query
integer
How far back to look (ignored if since is set) Default 10.
since
query
string (date-time)
Start (ISO 8601, UTC). Default: until, minus minutes
until
query
string (date-time)
End (ISO 8601, UTC). Default: now
limit
query
integer
Sample batches per page (one batch is a few seconds) Default 100.
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
Name
In
Type
Description
startrequired
query
string (date-time)
Windows starting from this time (ISO 8601)
endrequired
query
string (date-time)
Up to this time, not included (ISO 8601). At most 31 days after start
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
Name
In
Type
Description
device_idrequired
path
string
Device id (<org>-<10 hex>)
startrequired
query
string (date-time)
From this time (ISO 8601)
endrequired
query
string (date-time)
Up to this time, not included (ISO 8601). At most 24 hours after start
typerequired
query
string
Which part of the samples: presence, vitals, activity, gait, sleep, quality (with status, clock and transport) or features (any model's outputs)
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
Name
In
Type
Description
house_idrequired
path
string
House id (<org>-<10 hex>)
startrequired
query
string (date-time)
From this time (ISO 8601)
endrequired
query
string (date-time)
Up to this time, not included (ISO 8601). At most 24 hours after start
typerequired
query
string
Which part of the samples: presence, vitals, activity, gait, sleep, quality (with status, clock and transport) or features (any model's outputs)
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.
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
Name
In
Type
Description
limit
query
integer
Rows in the first page Default 500.
Request body 1
Field
Type
Description
sqlrequired
string
One 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"
}
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.
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
Name
In
Type
Description
webhook_idrequired
path
string
Request body 1
Field
Type
Description
frequency_minutes
integer
How often Zoiva delivers data.summary events to this webhook (in minutes). Device events (device.*, presence_changed) are always delivered immediately. Default 5.
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.
It can't get new tokens from now on. You can't delete your last client that has zoiva/clients.write.
Parameters 1
Name
In
Type
Description
client_idrequired
path
string
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.