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 exactlyAuthorization: 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 indetails[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-Resetis delta seconds;X-RateLimit-Resetis a unix timestamp;meta.rateLimit.resetin the body matchesX-RateLimit-Reset(unix), notRateLimit-Reset. Error responses have nometa.rateLimitat all, and401/402/403carry noRateLimit-*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.
| Param | Meaning |
|---|---|
from | Full ISO instant. Inclusive lower bound on startedAt. |
to | Full ISO instant. Inclusive upper bound on startedAt. |
taskId | Only logs against this task. |
customerId | Only logs for this client. |
userId | Only logs by this user. |
isBillable | true / false. |
source | manual, timer, or imported. |
limit, cursor | Keyset 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:
- 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.
from/toneed a full ISO instant.?from=2026-07-01returns400 validation_error— date-only and offset-less local times are rejected. Use2026-07-01T00:00:00Z.fromandtoare both inclusive, and they filter onstartedAt, not on the log's end. A log started at 23:50 with a 40-minute duration is returned byto=...T23:55:00Zeven though it ended the next day.durationSecondsis authoritative — do not derive it. It is not guaranteed to equalendedAt - 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.endedAtis the wall-clock truth and can benullfor a log that was never cleanly closed. Guard it.taskIdandcustomerIdcan benull(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:
- Verify before you trust. Anyone can POST to your URL. An unverified receiver is an open write endpoint.
- Use the raw body for the HMAC. Re-serialized JSON produces different bytes and verification fails.
- Deliveries are at-least-once. De-duplicate on the payload's
id, not onX-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.