Moonstreak
How it worksThe gameFeaturesPricingAboutBlogDocs
Log inStart free
Moonstreak

The time tracker that fights for your attention — and wins.

© 2026 Mánahöll ehf. · Reykjavík, Iceland
PRODUCTHow it worksThe gameFeaturesPricing
COMPANYAboutBlog
RESOURCESDocsHelp centerFAQ
LEGALPrivacyTermsSecurity
Docs

API Cookbook

Copy-pasteable recipes — your first request, paginating to exhaustion, handling 429 and 402, and a monthly invoicing export.

Last updated 2026-07-14
DOCSPublic API v1API CookbookWebhooksCalendar FeedClaude Code SkillON THIS PAGEYour first requestPaginating a full collectionHandling rate limitsHandling the plan gateReading time logsRecipe monthly billable hours exportReceiving webhooksVerifying signatures in PythonSubscribing to your calendar feedRelated

API Cookbook

Summary Copy-pasteable recipes for the Moonstreak public API — authentication, pagination, rate-limit and plan-gate handling, reading time logs, and an end-to-end monthly invoicing export. For the contract these recipes assume (envelope, error codes, scopes), see public-api-v1.md.

Every example uses two environment variables:

export MOONSTREAK_API_KEY="sk_..."   # sk_ + 64 hex, shown once at creation
export MOONSTREAK_BASE="https://your-domain.example/api/v1"

Your first request

Create a key in Settings → API keys, then prove it works. This lists your five most recent clients:

curl -s "$MOONSTREAK_BASE/customers?limit=5" \
  -H "Authorization: Bearer $MOONSTREAK_API_KEY"
{
  "data": [
    {
      "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
      "name": "Acme Ltd",
      "company": "Acme Ltd"
    }
  ],
  "meta": {
    "apiVersion": "v1",
    "timestamp": "2026-07-14T10:00:00.000Z",
    "rateLimit": { "limit": 1000, "remaining": 999, "reset": 1752487200 }
  },
  "pagination": { "hasMore": false }
}

Three failures worth recognising immediately:

  • 401 — the header is malformed. It must be exactly Authorization: Bearer sk_.... Keys are never read from a query string.
  • 402 — the workspace's plan doesn't include API access. See Handling the plan gate.
  • 403 — the key is valid but lacks the scope. The missing scope is named in details[0].message.

Paginating a full collection

Use case: you're reconciling Moonstreak clients against your accounting tool and need every client, not the first 50.

List endpoints use keyset cursors over (sort value, id). hasMore is exact — it comes from an over-fetch, not from guessing at page fullness — so a false means there is genuinely nothing left. Treat the cursor as opaque; it is base64url and its contents may change.

A malformed or tampered cursor returns 400 validation_error with details[0].field = "cursor". It is not silently treated as "page 1", so a loop that corrupts its cursor fails loudly instead of restarting forever.

const BASE = "https://your-domain.example/api/v1";
const KEY = process.env.MOONSTREAK_API_KEY;

/** Yields every item across every page of a v1 list endpoint. */
async function* paginate(path, params = {}) {
  let cursor;

  for (;;) {
    const qs = new URLSearchParams({ ...params, limit: "100" }); // 100 is the max
    if (cursor) qs.set("cursor", cursor);

    const res = await fetch(`${BASE}${path}?${qs}`, {
      headers: { Authorization: `Bearer ${KEY}` },
    });

    if (!res.ok) {
      const body = await res.json().catch(() => ({}));
      throw new Error(
        `${res.status} ${body.error ?? "unknown"}: ${body.message ?? ""}`,
      );
    }

    const body = await res.json();
    yield* body.data;

    if (!body.pagination?.hasMore) return;

    cursor = body.pagination.cursor;
    if (!cursor) return; // defensive: hasMore true but nothing to page from
  }
}

const clients = [];
for await (const c of paginate("/customers")) clients.push(c);
console.log(`${clients.length} clients`);

Python

import os
import requests

BASE = "https://your-domain.example/api/v1"
KEY = os.environ["MOONSTREAK_API_KEY"]


def paginate(path, **params):
    """Yield every item across every page of a v1 list endpoint."""
    session = requests.Session()
    session.headers["Authorization"] = f"Bearer {KEY}"
    cursor = None

    while True:
        query = {**params, "limit": 100}
        if cursor:
            query["cursor"] = cursor

        res = session.get(f"{BASE}{path}", params=query, timeout=30)
        if not res.ok:
            body = res.json() if res.content else {}
            raise RuntimeError(f"{res.status_code} {body.get('error')}: {body.get('message')}")

        body = res.json()
        yield from body["data"]

        pagination = body.get("pagination") or {}
        if not pagination.get("hasMore"):
            return

        cursor = pagination.get("cursor")
        if not cursor:
            return


clients = list(paginate("/customers"))
print(f"{len(clients)} clients")

Handling rate limits

Each key gets 1000 requests/hour by default. On a 429, Retry-After tells you how long the current window has left.

Retry-After can be as large as 3600. It is the time remaining in the hour-aligned window, not a short backoff — sleeping on it blindly parks your script for up to an hour. Cap the wait and decide deliberately:

const MAX_WAIT_MS = 60_000; // never block longer than this on one attempt

async function apiFetch(url, init = {}, { maxRetries = 5 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, {
      ...init,
      headers: {
        Authorization: `Bearer ${process.env.MOONSTREAK_API_KEY}`,
        ...init.headers,
      },
    });

    if (res.status !== 429 || attempt >= maxRetries) return res;

    const retryAfter = Number(res.headers.get("Retry-After")); // delta seconds
    const serverWaitMs =
      Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 0;
    const backoffMs = Math.min(2 ** attempt * 1000, MAX_WAIT_MS);
    const waitMs = Math.min(serverWaitMs || backoffMs, MAX_WAIT_MS);

    if (serverWaitMs > MAX_WAIT_MS) {
      console.warn(
        `Rate limited; window resets in ${retryAfter}s. Retrying in ${waitMs / 1000}s anyway ` +
          `(attempt ${attempt + 1}/${maxRetries}).`,
      );
    }
    await new Promise((r) => setTimeout(r, waitMs));
  }
}

Better: don't get rate limited

Every successful response carries your remaining budget. Slow down before you hit zero rather than reacting to a 429:

const res = await apiFetch(`${BASE}/customers?limit=100`);

const remaining = Number(res.headers.get("RateLimit-Remaining"));
const resetIn = Number(res.headers.get("RateLimit-Reset")); // delta seconds

if (Number.isFinite(remaining) && remaining < 50) {
  // Spread what's left over the rest of the window.
  const paceMs = Math.ceil((resetIn * 1000) / Math.max(remaining, 1));
  await new Promise((r) => setTimeout(r, paceMs));
}

Header semantics, exactly. RateLimit-Reset is delta seconds; X-RateLimit-Reset is a unix timestamp; meta.rateLimit.reset in the body matches X-RateLimit-Reset (unix), not RateLimit-Reset. Error responses have no meta.rateLimit at all, and 401 / 402 / 403 carry no RateLimit-* headers either — read the headers only where they exist.


Handling the plan gate

API access requires a plan that grants it. The plan is re-checked on every request, not just at key creation — if a workspace downgrades, existing keys stop working on the very next call. Treat 402 as a durable, human-fix state: retrying will not clear it.

const res = await fetch(`${BASE}/tasks`, {
  headers: { Authorization: `Bearer ${process.env.MOONSTREAK_API_KEY}` },
});

if (res.status === 402) {
  // body.error === "plan_required"
  throw new Error(
    "Moonstreak API access is not enabled for this workspace. " +
      "Upgrade the plan, then retry — the key itself is still valid.",
  );
}
{
  "error": "plan_required",
  "message": "API access requires a plan with API access.",
  "meta": { "apiVersion": "v1", "timestamp": "2026-07-14T10:00:00.000Z" }
}

402 vs 403. 402 is about the workspace's plan — upgrade to fix. 403 is about the key's scopes — issue a new key with the right scopes to fix, and read details to learn which scope was missing.

The calendar feed is free on every plan and is not affected by this gate. If all you need is hours in a calendar, see ical-feed.md — no API key, no plan requirement.


Reading time logs

Use case: a standup bot that posts what you logged yesterday.

GET /api/v1/time-logs — scope time-logs:read.

ParamMeaning
fromFull ISO instant. Inclusive lower bound on startedAt.
toFull ISO instant. Inclusive upper bound on startedAt.
taskIdOnly logs against this task.
customerIdOnly logs for this client.
userIdOnly logs by this user.
isBillabletrue / false.
sourcemanual, timer, or imported.
limit, cursorKeyset pagination; the cursor sorts on startedAt.

Results are newest-first (startedAt descending).

curl -s "$MOONSTREAK_BASE/time-logs?from=2026-07-13T00:00:00Z&to=2026-07-13T23:59:59Z&limit=2" \
  -H "Authorization: Bearer $MOONSTREAK_API_KEY"
{
  "data": [
    {
      "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "taskId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
      "userId": "1b4e28ba-2fa1-11d2-883f-0016d3cca427",
      "customerId": "c9bf9e57-1685-4c89-bafb-ff5af830be8a",
      "source": "timer",
      "description": "Client call + follow-up",
      "tags": [],
      "startedAt": "2026-07-13T09:30:00.000Z",
      "endedAt": "2026-07-13T10:00:00.000Z",
      "durationSeconds": 1800,
      "isBillable": true
    }
  ],
  "meta": {
    "apiVersion": "v1",
    "timestamp": "2026-07-14T10:00:00.000Z",
    "rateLimit": { "limit": 1000, "remaining": 998, "reset": 1752487200 }
  },
  "pagination": {
    "hasMore": true,
    "cursor": "eyJjIjoiMjAyNi0wNy0xM1QwOTozMDowMC4wMDBaIiwiaSI6IjliMWRlYjRkIn0"
  }
}

Five things to know before you build on this:

  1. A running timer produces no row. In-progress timers live in a separate table and are not exposed here — only committed logs are. If "today's hours" look short, this is almost always why. It is not a bug, and polling harder will not summon the row; it appears when the timer stops.
  2. from / to need a full ISO instant. ?from=2026-07-01 returns 400 validation_error — date-only and offset-less local times are rejected. Use 2026-07-01T00:00:00Z.
  3. from and to are both inclusive, and they filter on startedAt, not on the log's end. A log started at 23:50 with a 40-minute duration is returned by to=...T23:55:00Z even though it ended the next day.
  4. durationSeconds is authoritative — do not derive it. It is not guaranteed to equal endedAt - startedAt: discarded idle time, manual edits, rounding, and a 12-hour clamp all move it. Recomputing from the timestamps produces numbers that disagree with the app and with your invoices. endedAt is the wall-clock truth and can be null for a log that was never cleanly closed. Guard it.
  5. taskId and customerId can be null (a log against no task, or a task with no client). All timestamps are UTC; a user's "day" depends on their timezone, so convert before bucketing by day.

Fields deliberately not exposed: notes (free text), organizationId, clientSessionId, and row bookkeeping. There is no rate or money column on a time log at all — billing lives on the client.


Recipe monthly billable hours export

Use case: it's the 1st. You bill Acme monthly. You want last month's billable hours broken down by task, as CSV you can paste into an invoice — without opening the app.

Needs a key with customers:read, time-logs:read, and tasks:read.

#!/usr/bin/env node
// monthly-invoice.js — usage: node monthly-invoice.js "Acme Ltd"
// Uses paginate() from "Paginating a full collection" above.

const clientName = process.argv[2];
if (!clientName)
  throw new Error('usage: node monthly-invoice.js "<client name>"');

// --- 1. Last calendar month, in UTC ----------------------------------------
// `to` is INCLUSIVE, so it must be the last instant of the month rather than
// midnight on the 1st — otherwise a log started exactly at 00:00:00.000 on the
// 1st gets billed twice, once in each month.
const now = new Date();
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) - 1);

// --- 2. Resolve the client name to an id -----------------------------------
let client;
for await (const c of paginate("/customers")) {
  if (c.name.toLowerCase() === clientName.toLowerCase()) client = c;
}
if (!client) throw new Error(`No client named "${clientName}"`);

// --- 3. That client's billable logs for the window -------------------------
const logs = [];
for await (const log of paginate("/time-logs", {
  customerId: client.id,
  from: from.toISOString(),
  to: to.toISOString(),
  isBillable: "true",
})) {
  logs.push(log);
}

// --- 4. Resolve task titles ------------------------------------------------
// A time log carries `taskId`, not a task title, so build the lookup once
// rather than issuing a request per log.
const taskTitles = new Map();
for await (const t of paginate("/tasks", { customerId: client.id })) {
  taskTitles.set(t.id, t.title);
}

// --- 5. Group by task ------------------------------------------------------
// Trust `durationSeconds`; never recompute it from the timestamps.
const byTask = new Map();
for (const log of logs) {
  const label = (log.taskId && taskTitles.get(log.taskId)) || "(no task)";
  byTask.set(label, (byTask.get(label) ?? 0) + log.durationSeconds);
}

// --- 6. Emit CSV -----------------------------------------------------------
console.log("task,hours");
let total = 0;
for (const [task, seconds] of [...byTask].sort((a, b) => b[1] - a[1])) {
  total += seconds;
  console.log(`"${task.replaceAll('"', '""')}",${(seconds / 3600).toFixed(2)}`);
}
console.error(
  `\n${clientName} — ${from.toISOString().slice(0, 7)}: ${(total / 3600).toFixed(2)} billable hours`,
);
task,hours
"Website rebuild",41.25
"Retainer support",12.50

Acme Ltd — 2026-06: 53.75 billable hours

Run it on the 1st and a timer left running overnight will not be in the export — see point 1 under Reading time logs. Stop the timer first.

Prefer not to script it? Settings → Export produces CSV from the app, and Moonstreak can generate the invoice itself from Reports.


Receiving webhooks

Polling the API on a timer is the wrong shape for "tell me when a timer stops" — subscribe instead. You get a signed POST within seconds and spend none of your rate-limit budget.

A complete, copy-pasteable Node/Express receiver (raw-body handling, signature verification, idempotency) lives in the webhooks doc rather than being duplicated here, where the two copies would drift:

  • Webhooks → Node.js example — the full receiver.
  • Webhooks → Verifying signatures — the scheme.
  • Webhooks → Event catalog — what you can subscribe to.

Three things that bite people, all covered there:

  1. Verify before you trust. Anyone can POST to your URL. An unverified receiver is an open write endpoint.
  2. Use the raw body for the HMAC. Re-serialized JSON produces different bytes and verification fails.
  3. Deliveries are at-least-once. De-duplicate on the payload's id, not on X-Moonstreak-Delivery-Id, which differs per attempt.

Verifying signatures in Python

The Node reference is the canonical version; this is the same construction, tolerance, and constant-time comparison in Python.

import hashlib
import hmac
import time


def verify_moonstreak_signature(secret: str, raw_body: bytes, header: str,
                                tolerance_seconds: int = 300) -> bool:
    """`header` is the X-Moonstreak-Signature value: 't=<unix>,v1=<hex>'."""
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        timestamp = int(parts["t"])
        signature = parts["v1"]
    except (ValueError, KeyError):
        return False

    # Replay window.
    if abs(int(time.time()) - timestamp) > tolerance_seconds:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.".encode("utf-8") + raw_body,  # raw bytes, never re-serialized JSON
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Flask, using the raw body (request.get_data(), not request.json):

import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/webhooks/moonstreak")
def moonstreak_webhook():
    ok = verify_moonstreak_signature(
        os.environ["MOONSTREAK_WEBHOOK_SECRET"],
        request.get_data(),
        request.headers.get("X-Moonstreak-Signature", ""),
    )
    if not ok:
        return "invalid signature", 401

    event = request.get_json()
    if already_processed(event["id"]):  # at-least-once delivery
        return "ok", 200
    handle(event)
    return "ok", 200

Subscribing to your calendar feed

If the goal is "my hours, in my calendar", you don't need the API at all — and you don't need a paid plan. Moonstreak publishes a read-only .ics feed any calendar app can subscribe to.

See ical-feed.md for the subscribe walkthrough (Google, Apple, Outlook) and exactly what the feed does and does not expose.


Related

  • public-api-v1.md — the contract: envelope, errors, scopes, rate limits.
  • webhooks.md — push events, the read counterpart of /time-logs.
  • ical-feed.md — the free read-only calendar feed.