bulk roost creation from csv
a node script that reads a csv of siteId,roostName,targets rows and calls POST /api/roosts for each one. every row derives a deterministic roostId from siteId and roostName, then sends that id in the create body. re-runs are safe because unchanged rows either create once or return a generic 409 conflict; on conflict, the script reads the existing roost with GET /api/roosts/{roostId}?siteId=... and records it as skipped. partial failure is the default: one bad row doesn't halt the rest, transient 5xx and 429 responses are retried with backoff, permanent 4xx responses are logged and counted as failed. the script ends with a summary of created / skipped-existing / failed counts and a non-zero exit code if anything failed.
required env vars
OWLETTE_TOKEN- api key withsite=<site-id>:writeplusroost=<roost-id>:readfor each deterministic roost id, orroost=*:readif the operator accepts wildcard roost read for the user's accessible sites.OWLETTE_API_URL-https://owlette.apporhttps://dev.owlette.app.
sample input - roosts.csv
siteId,roostName,targets
kiosk-fleet-01,lobby-display,machine-a7f3|machine-b2c1
kiosk-fleet-01,cafeteria-menu,machine-c3d4
kiosk-fleet-01,reception-loop,machine-e5f6|machine-g7h8|machine-i9j0
kiosk-fleet-02,west-wing-signage,machine-k1l2|machine-m3n4
kiosk-fleet-02,parking-kiosk,machine-o5p6rules:
- header row required; exact column names
siteId,roostName,targets. targetsis pipe-separated machine ids (|), each optionally whitespace-padded.- comma inside fields is not supported (keep roost names and site ids to
[a-z0-9-]). - blank lines and
#-prefixed comment lines are ignored.
bulk-create-roosts.mjs
#!/usr/bin/env node
// bulk-create-roosts.mjs
// usage: node bulk-create-roosts.mjs ./roosts.csv
// exits: 0 = all created/skipped cleanly, 1 = one or more permanent failures
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
const ROOST_VERSION = '2026-04-22';
const { OWLETTE_TOKEN, OWLETTE_API_URL } = process.env;
const csvPath = process.argv[2];
if (!OWLETTE_TOKEN || !OWLETTE_API_URL) {
console.error('error: OWLETTE_TOKEN and OWLETTE_API_URL must be set');
process.exit(1);
}
if (!csvPath) {
console.error('usage: node bulk-create-roosts.mjs <csvPath>');
process.exit(1);
}
const H = {
authorization: `Bearer ${OWLETTE_TOKEN}`,
'roost-version': ROOST_VERSION,
'content-type': 'application/json',
};
function roostIdFor(siteId, name) {
return 'rst_' + createHash('sha256')
.update(`${siteId}|${name}`)
.digest('hex')
.slice(0, 18);
}
function parseCsv(text) {
const rows = [];
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line.startsWith('#')) continue;
if (i === 0 && line.toLowerCase().startsWith('siteid')) continue; // header
const [rawSiteId, rawName, rawTargets] = line.split(',');
if (!rawSiteId || !rawName || rawTargets === undefined) {
rows.push({ lineNo: i + 1, error: 'malformed row', raw: line });
continue;
}
const siteId = rawSiteId.trim();
const name = rawName.trim();
const targets = rawTargets.split('|').map(t => t.trim()).filter(Boolean);
rows.push({
lineNo: i + 1,
siteId,
name,
roostId: roostIdFor(siteId, name),
targets,
});
}
return rows;
}
function parseJsonMaybe(text) {
try {
return text ? JSON.parse(text) : {};
} catch {
return { detail: text };
}
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
function rateLimitPaceMs(headers) {
const remaining = Number.parseInt(headers.get('ratelimit-remaining') || '', 10);
const reset = Number.parseInt(headers.get('ratelimit-reset') || '', 10);
if (!Number.isFinite(remaining) || !Number.isFinite(reset)) return 0;
if (remaining > 1 || reset <= 0) return 0;
return Math.min(60_000, reset * 1000);
}
async function readExistingRoost(row, attempt, postPaceMs) {
const url = `${OWLETTE_API_URL}/api/roosts/${encodeURIComponent(row.roostId)}?siteId=${encodeURIComponent(row.siteId)}`;
const res = await fetch(url, { headers: H });
const responseBody = parseJsonMaybe(await res.text());
const paceMs = Math.max(postPaceMs, rateLimitPaceMs(res.headers));
if (res.ok) {
return {
outcome: 'skipped',
reason: 'already exists',
id: responseBody.roostId || row.roostId,
row,
attempts: attempt,
paceMs,
};
}
return {
outcome: 'failed',
row,
attempts: attempt,
status: res.status,
code: responseBody.code,
detail: `create returned 409 conflict, then lookup failed: ${responseBody.detail || res.statusText}`,
param: responseBody.param,
errors: responseBody.errors,
paceMs,
};
}
async function createRoost(row, { maxAttempts = 4 } = {}) {
const body = JSON.stringify({
siteId: row.siteId,
roostId: row.roostId,
name: row.name,
targets: row.targets,
});
let attempt = 0;
while (true) {
attempt++;
const res = await fetch(`${OWLETTE_API_URL}/api/roosts`, {
method: 'POST',
headers: H,
body,
});
const responseBody = parseJsonMaybe(await res.text());
const paceMs = rateLimitPaceMs(res.headers);
if (res.ok) {
return {
outcome: 'created',
id: responseBody.roostId || row.roostId,
row,
attempts: attempt,
paceMs,
};
}
if (res.status === 409 && responseBody.code === 'conflict') {
return await readExistingRoost(row, attempt, paceMs);
}
// transient: retry with jittered backoff
const retryable = res.status >= 500 || res.status === 429;
if (retryable && attempt < maxAttempts) {
const retryAfter = parseInt(res.headers.get('retry-after') || '0', 10);
const backoffMs = retryAfter > 0
? retryAfter * 1000
: Math.min(30_000, 500 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 250);
console.warn(JSON.stringify({
ts: new Date().toISOString(),
level: 'warn',
msg: 'transient failure, retrying',
row: { siteId: row.siteId, roostId: row.roostId, name: row.name },
attempt, status: res.status, code: responseBody.code, backoffMs,
}));
await sleep(backoffMs);
continue;
}
// permanent 4xx (or retries exhausted) - log and move on
return {
outcome: 'failed',
row,
attempts: attempt,
status: res.status,
code: responseBody.code,
detail: responseBody.detail,
param: responseBody.param,
errors: responseBody.errors,
paceMs,
};
}
}
async function main() {
const csv = await readFile(csvPath, 'utf8');
const rows = parseCsv(csv);
const summary = { created: [], skipped: [], failed: [], malformed: [] };
for (const row of rows) {
if (row.error) {
summary.malformed.push({ lineNo: row.lineNo, error: row.error, raw: row.raw });
continue;
}
const result = await createRoost(row);
if (result.outcome === 'created') {
summary.created.push({ lineNo: row.lineNo, name: row.name, roostId: result.id });
} else if (result.outcome === 'skipped') {
summary.skipped.push({
lineNo: row.lineNo,
name: row.name,
roostId: result.id,
reason: result.reason,
});
} else {
summary.failed.push({
lineNo: row.lineNo,
name: row.name,
roostId: row.roostId,
status: result.status,
code: result.code,
detail: result.detail,
param: result.param,
errors: result.errors,
});
}
if (result.paceMs > 0) {
console.warn(JSON.stringify({
ts: new Date().toISOString(),
level: 'warn',
msg: 'rate limit window nearly empty, pacing',
row: { siteId: row.siteId, roostId: row.roostId, name: row.name },
sleepMs: result.paceMs,
}));
await sleep(result.paceMs);
}
}
console.log(JSON.stringify({
createdCount: summary.created.length,
skippedExistingCount: summary.skipped.length,
failedCount: summary.failed.length,
malformedCount: summary.malformed.length,
details: summary,
}, null, 2));
if (summary.failed.length > 0 || summary.malformed.length > 0) process.exit(1);
}
await main();the script parses the csv tolerantly (blank lines, comments, header row all handled), derives a stable roostId from sha256(siteId|roostName), then calls POST /api/roosts with that id in the json body. it does not send Idempotency-Key: this create route does not cache mutation replays. a 201 response is recorded as created; a generic 409 conflict is followed by GET /api/roosts/{roostId}?siteId=... and recorded as skipped when the existing roost can be read. 5xx and 429 retry up to 4 times with jittered backoff capped at 30s, or Retry-After when the server supplies it. other 4xx responses become failed entries with the server's code, detail, and param so the operator sees exactly which column or value was bad.
when responses include RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset, the script uses those live headers for pacing. if the active route or environment does not return rate-limit counters, the script still handles 429 rate_limited through Retry-After and exponential backoff.
rerun safety
because the roost id is rst_<sha256(siteId|roostName)>, a re-run after fixing one typo targets the same document id for every unchanged row. rows that already exist return 409 conflict; the script reads the current roost and records the row as skipped instead of treating the conflict as fatal. if you need to change targets for an existing roost, use the dashboard or PATCH /api/roosts/{id} rather than rerunning this create script.
sample output
{
"createdCount": 4,
"skippedExistingCount": 1,
"failedCount": 0,
"malformedCount": 0,
"details": {
"created": [
{ "lineNo": 2, "name": "lobby-display", "roostId": "rst_823b5ac773f075f016" },
{ "lineNo": 4, "name": "reception-loop", "roostId": "rst_51571639914a0a4863" },
{ "lineNo": 5, "name": "west-wing-signage", "roostId": "rst_fefec5f4c9ca6bd060" },
{ "lineNo": 6, "name": "parking-kiosk", "roostId": "rst_59211baab2074d61d3" }
],
"skipped": [
{
"lineNo": 3,
"name": "cafeteria-menu",
"roostId": "rst_30829c19902e49aba1",
"reason": "already exists"
}
],
"failed": [],
"malformed": []
}
}error handling summary
409 conflict- read the existing roost and treat it asskippedif lookup succeeds. if lookup fails, record afailedrow with the lookup error.400 validation_failedor a route-specific validation code - logged tofailed[]with field errors when present so the operator knows which column broke (for example,{"errors":{"body.targets":["must be string[]"]}}). halts nothing.403 scope_insufficient- logged tofailed[]. doesn't halt the run because different rows may reference different sites; a key missing scope on one site can still succeed on another.404 not_found- logged tofailed[]. same reasoning as above.429 rate_limited- honoursRetry-Afterand retries. whenRateLimit-*response headers are present, use them for pacing instead of a hard-coded request budget.5xx- retries up to 4 times with jittered exponential backoff.- malformed csv rows - recorded in
malformed[]and counted toward the non-zero exit code, but don't halt processing of later rows.
nightly directory sync
a raw REST node script that runs nightly on a build box, walks a local directory, diffs against the roost's currently published version, and publishes a new version only if something actually changed. most nights nothing has changed — the script must not publish empty versions or burn quota. on quota_exceeded it sends an optional webhook alert; structured logs go to stdout for systemd journald / windows event log capture.
auto-rollback on deployment.failed webhook
a small node/express webhook receiver that listens for deployment.failed events from roost, verifies the Roost-Signature hmac, calls POST /api/roosts/{roostId}/rollback, and notifies a slack channel. automatic production delivery of deployment.failed is still future/launch-blocked; use this receiver with POST /api/webhooks/probe to test signature handling and keep real rollback execution operator-gated until event fanout is enabled. deploy it anywhere that can accept https requests - vercel, cloudflare workers, or a plain node server behind nginx. the same code shape runs in all three with minor entrypoint tweaks.