Spoar

Admin

Manage alert targets and annotations and read stats from server code and scripts with a token, using the `read` scope for the read methods and the `admin` scope for alert targets and annotation writes.

@spoar/sdk/admin is for server code, scripts and CI. It calls the API with a token; never ship it to a browser. It is a thin layer over @spoar/client, which covers every read route as a chainable scope; new code should use that package directly.

import { createAdmin, discord, mail, webhook } from "@spoar/sdk/admin";

type Projects = "remcostoeten.nl" | "skriuw";

const admin = createAdmin<Projects>({
  endpoint: "https://api.analytics.remcostoeten.nl",
  token: process.env.RA_ADMIN_TOKEN,
});

function isHttps(url: string | undefined): url is `https://${string}` {
  return url?.startsWith("https://") ?? false;
}

const discordUrl = process.env.DISCORD_WEBHOOK_URL;
if (!isHttps(discordUrl)) throw new Error("Set DISCORD_WEBHOOK_URL to the Discord webhook URL");

const synced = await admin.alerts.sync("remcostoeten.nl", [
  mail({ to: ["remco@gmail.com"] }),
  discord({ url: discordUrl, on: ["issue.regression"] }),
  webhook({ name: "ops", url: "https://ops.example.com/hooks/analytics" }),
]);

if (!synced.ok) console.error(synced.error.code, synced.error.message);
else console.log(synced.value.secrets.ops);

Every method answers { ok: true, value } or { ok: false, error } and never throws. error.code is a code from the error catalog, or NO_TOKEN, NETWORK, TIMEOUT, ABORTED, BAD_URL or BAD_RESPONSE when the request did not get an answer from the API.

Alert targets

A target is where one project's alerts go on one channel. The builders fill one in:

BuilderTakes
mail({ to, name?, on?, enabled? })One to 20 addresses
webhook({ url, name?, on?, enabled? })An https:// URL; the API signs each request
discord({ url, name?, on?, enabled? })A Discord webhook URL

name defaults to the channel, so one target per channel needs no name. on defaults to every issue event (issue.new and issue.regression) and enabled to true. speed.drop is opt-in: list it in on to hear when yesterday's Real Experience Score fell 10 points or more, to under 90, against the 7 days before.

MethodDoes
alerts.sync(project, targets)Makes the project's targets match the list: adds, updates and removes; running it twice changes nothing. Answers { created, updated, removed, secrets }
alerts.list(project)The targets with their state: active, paused or failing, and stateReason
alerts.set(project, target)Creates or replaces one target
alerts.remove(project, name)Removes one target and its history
alerts.test(project, name)Sends a sample alert now and answers { delivered, message } with what the provider said
alerts.rotate(project, name)A new signing secret for a webhook target
alerts.deliveries(project, { status?, limit?, cursor? })The delivery history, newest first
alerts.status()Enabled channels, the mail transport, pending deliveries and failing targets

secrets holds the signing secret of each new webhook target. It is shown only in the answer to sync, set and rotate; store it where your receiver reads it, such as RA_WEBHOOK_SECRET.

What the editor catches

MistakeCaught by
createAdmin<Projects> then sync("remcostoten.nl", ...)Type error: not in Projects
on: ["issue.created"]Type error: offers issue.new and issue.regression
mail({ to: [] })Type error: at least one address
mail({ to: ["remco"] })Type error: ${string}@${string}.${string}
webhook({ url: "http://..." })Type error: https:// only
Two targets with the same literal nameType error on the list
A mail target on a deployment without mail()VALIDATION_FAILED: "mail is not enabled on this deployment"
annotations.create(project, { date: "1 October" })Type error: a Date, a calendar date or an ISO 8601 timestamp
annotations.create(project, { url: "ftp://..." })Type error: http:// or https:// only
annotations.create(project, { kind: "campaign" })Type error: release, post, content, incident or other
annotations.update(project, id, {})Type error: at least one change
endDate before dateVALIDATION_FAILED at /endDate

Annotations

Annotations are dated labels on a project's time series, such as a release, a post, a content update or an incident. Annotations describes the data model.

const release = await admin.annotations.create("remcostoeten.nl", {
  title: "v2.0 released",
  date: new Date(),
  kind: "release",
  url: "https://github.com/remcostoeten/analytics/releases/tag/v2.0.0",
});

await admin.annotations.create("skriuw", {
  title: "Posted on Hacker News",
  date: "2026-10-03",
  endDate: "2026-10-05",
  kind: "post",
});

if (release.ok) {
  await admin.annotations.update("remcostoeten.nl", release.value.id, {
    note: "New onboarding flow",
  });
}

const september = await admin.annotations.list("remcostoeten.nl", {
  from: "2026-09-01T00:00:00Z",
  to: "2026-10-01T00:00:00Z",
});
if (september.ok) console.table(september.value.data);
MethodDoes
annotations.list(project, { period?, from?, to?, limit?, cursor? })The annotations that overlap the range, oldest first, with nextCursor
annotations.create(project, { title, date, endDate?, kind?, note?, url? })Adds one and answers it
annotations.update(project, id, changes)Changes the fields given; null clears endDate, note or url
annotations.remove(project, id)Deletes one

Dates are a Date, a calendar date such as "2026-10-01" (the start of that day in UTC) or an ISO 8601 timestamp. list works with a read token; the other methods need an admin token that lists the project.

Reads

The same client reads the stats routes, typed from the contract, for your own dashboard pages and server components:

MethodRoute
stats(project, { period?, from?, to?, traffic?, filter? })GET /v2/projects/:project/stats
timeseries(project, options)GET /v2/projects/:project/timeseries
breakdown(project, dimension, options)GET /v2/projects/:project/breakdown/:dimension
lifecycle(project, options)GET /v2/projects/:project/lifecycle
issues(project, { status?, limit?, cursor? })GET /v2/projects/:project/issues

filter: { country: "NL" } is sent as filter[country]=NL.

On this page