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

Public API v1

The REST contract: authentication, the response envelope, error codes, pagination, and rate limits.

Last updated 2026-07-31
DOCSPublic API v1API CookbookWebhooksCalendar FeedClaude Code SkillON THIS PAGEBase URLAuthenticationThe response envelopePaginationRate limitsEndpoint referenceRelated

Public API: /api/v1/*

Summary The public REST API lets external applications (Zapier, custom scripts, future first-party integrations) read and write a Moonstreak organization's tasks, customers, constellations (projects), and time logs. It is a paid feature — API keys only work for organizations whose plan grants customIntegrations — and every request is authenticated by an API key, never by session cookie.

This document covers the contract that is shared across every /api/v1/* route: authentication, the response envelope, error codes, pagination, and rate limits. See the endpoint reference at the bottom for the specific resources.

  • For working code — first request, pagination loops, 429/402 handling, a monthly invoicing export — see api-cookbook.md.
  • For outbound webhooks (event subscriptions, delivery, signing), see webhooks.md.
  • For the free read-only calendar feed (no API key, no plan gate), see ical-feed.md.

Base URL

https://<your-domain>/api/v1

All public API routes live under this prefix. There is currently one version (v1); the envelope's meta.apiVersion field always reads "v1".


Authentication

Every request must include an API key as a Bearer token:

Authorization: Bearer sk_3f8a1c2b9d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8

Key format

A key is the literal prefix sk_ followed by 64 lowercase hex characters (32 random bytes) — 67 characters total. There is one key namespace: Moonstreak has no separate live/test or sandbox keys, and no sk_live_ / sk_test_ distinction. To test against throwaway data, create a second organization and issue a key there.

  • Keys are never accepted as a query string parameter (?api_key=...) — only the Authorization header. Query-string keys leak into server logs, browser history, and analytics tools; the API rejects them outright by never looking for them.
  • A missing or malformed header, or a key that fails validation, returns 401 unauthorized.

Key lifecycle

API keys are managed from the app, not from the public API itself:

  1. Create: Settings → API keys → "Create key". You choose a name and a set of permission scopes. The plain-text key is shown exactly once, at creation time — copy it immediately. Only a SHA-256 hash of the key is ever stored; if you lose the plain-text value there is no way to recover it, only to revoke the key and create a new one. This applies to API keys specifically — calendar feed tokens use a different model and are stored retrievably so a feed URL can be re-shown; see ical-feed.md for why.
  2. List: Settings → API keys shows every key's name, permissions, creation date, and last-used timestamp. The plain-text key and its hash are never shown again after creation.
  3. Revoke: deleting a key from Settings → API keys immediately deactivates it (isActive = false). Revoked keys return 401 unauthorized on every subsequent request; they are not deleted from the database (so delivery/audit history that references the key by ID stays intact).

Creating an API key itself requires a plan with API access — see per-request plan gating below for what happens if the org's plan changes after a key already exists.

Permission scopes

Format: resource:action. A key can hold any combination of scopes; missing a required scope for an endpoint returns 403 forbidden.

ScopeGrants
tasks:readGET task endpoints
tasks:writePOST / PATCH task endpoints
tasks:deleteDELETE task endpoints
tasks:*all task actions
time-logs:readGET /api/v1/time-logs
customers:readGET customer endpoints
customers:writePOST customer endpoints
customers:*all customer actions
constellations:readGET constellation (project) endpoints
constellations:writePOST constellation endpoints
constellations:*all constellation actions
*every permission (admin key)

Wildcards are prefix-matched: tasks:* satisfies a route that requires tasks:read. * satisfies every route.

tasks:delete is the only delete scope with a route behind it (DELETE /api/v1/tasks/:taskId). Customers and constellations have no public delete endpoint, so there is no customers:delete / constellations:delete to grant — remove them from the app instead.

There is deliberately no time-logs:*. Its prefix would match a future time-logs:write and silently grant write to every key minted today, with no user action and no audit event. The wildcard arrives in the same change that adds a write scope, when the grant is an informed one.

Per-request plan gating

Unlike key creation (gated once, at the moment you click "Create key"), every single public API request re-checks that the organization's current plan still grants customIntegrations. If an org downgrades to a plan without API access, its existing keys stop working on the very next request — they are not silently grandfathered in.

A gated request returns:

{
  "error": "plan_required",
  "message": "API access requires a plan with API access.",
  "meta": { "apiVersion": "v1", "timestamp": "2026-07-12T10:00:00.000Z" }
}

with HTTP status 402 Payment Required.


The response envelope

Every /api/v1/* response — success or error — is JSON with a consistent shape. It is built in exactly one place; no route hand-rolls it, so you can rely on the shape being identical across every endpoint.

Success

{
  "data": { "...": "the resource, or an array of resources" },
  "meta": {
    "apiVersion": "v1",
    "timestamp": "2026-07-12T10:00:00.000Z",
    "rateLimit": { "limit": 1000, "remaining": 987, "reset": 1752314400 }
  },
  "pagination": {
    "hasMore": true,
    "cursor": "eyJjIjoiMjAyNi0wNy0xMlQxMDowMDowMC4wMDBaIiwiaSI6InRhc2tfMSJ9"
  }
}
  • data — the requested resource, or an array of resources for list endpoints.
  • meta.rateLimit — present on successful responses only. Error responses (401, 402, 403, 429, 500) never carry it; read the RateLimit-* response headers instead, which are set on more paths (see rate limits). meta.rateLimit.reset is a unix timestamp — the same semantics as X-RateLimit-Reset, not RateLimit-Reset, which is a delta.
  • pagination — present only on list endpoints. cursor is present only when hasMore is true; pass it back as ?cursor= to fetch the next page. total, when present, is a best-effort count (not every list endpoint computes it).

Error

{
  "error": "validation_error",
  "message": "Request validation failed",
  "details": [{ "field": "title", "message": "Required" }],
  "meta": { "apiVersion": "v1", "timestamp": "2026-07-12T10:00:00.000Z" }
}
  • error — a stable, machine-readable code (see the table below). Match on this, not on message, which is a human sentence that may change.
  • details — present for validation errors (one entry per invalid field) and for 403 forbidden (which permission scope was missing).
  • 5xx errors never leak internal details in message — the server logs the real error and returns a generic message.

Error codes

errorHTTP statusWhen
validation_error400Request body/query failed Zod validation.
unauthorized401Missing/malformed Authorization header, or an invalid/expired key.
plan_required402The org's current plan doesn't grant API access (see gating above).
forbidden403The key is valid but lacks a required permission scope.
not_found404The requested resource doesn't exist (or belongs to another org).
conflict409The request conflicts with existing state.
rate_limit_exceeded429The key has exceeded its per-hour rate limit. See below.
internal_error500Unexpected server error. Logged server-side; details never leaked.

Example: 401 unauthorized (bad key)

{
  "error": "unauthorized",
  "message": "Invalid or expired API key.",
  "meta": { "apiVersion": "v1", "timestamp": "2026-07-12T10:00:00.000Z" }
}

Example: 403 forbidden (missing scope)

{
  "error": "forbidden",
  "message": "Insufficient permissions for this API key.",
  "details": [
    { "field": "permission", "message": "Required permission: tasks:write" }
  ],
  "meta": { "apiVersion": "v1", "timestamp": "2026-07-12T10:00:00.000Z" }
}

Example: 404 not_found

{
  "error": "not_found",
  "message": "Task with ID task_123 not found",
  "meta": { "apiVersion": "v1", "timestamp": "2026-07-12T10:00:00.000Z" }
}

Example: 429 rate_limit_exceeded

{
  "error": "rate_limit_exceeded",
  "message": "Rate limit exceeded. Please retry after the window resets.",
  "meta": { "apiVersion": "v1", "timestamp": "2026-07-12T10:00:00.000Z" }
}

Response headers on a 429 additionally include Retry-After — see rate limits.


Pagination

List endpoints (GET /tasks, GET /customers, GET /constellations, GET /time-logs) use opaque keyset cursor pagination — not page numbers or offsets, which drift under concurrent writes.

  • limit — query param, 1–100, default 50.
  • cursor — query param, optional. Omit for the first page; pass the previous response's pagination.cursor to fetch the next page.
  • The cursor is an opaque base64url string encoding (sort value, id) of the last item on the page. The sort value is createdAt for tasks, customers, and constellations, and startedAt for time logs. Treat it as opaque — its internal shape may change.
  • pagination.hasMore tells you whether to keep paging; pagination.cursor is only present when hasMore is true (there's nothing to encode past the last page). hasMore is exact: it comes from reading one row beyond the page, so false means there is genuinely nothing left and true always yields at least one more item.

Changed 2026-07-14 — a malformed or tampered cursor now returns 400 validation_error with details[0].field = "cursor". It was previously treated as "first page", which turned a corrupted cursor into an infinite loop for any client paginating until hasMore === false. If you were relying on a bad cursor silently restarting, you will now see a 400.

GET /api/v1/tasks?limit=25
GET /api/v1/tasks?limit=25&cursor=eyJjIjoiMjAyNi0wNy0xMlQxMDowMDowMC4wMDBaIiwiaSI6InRhc2tfMSJ9

Rate limits

Each API key has an hourly request limit — 1000 requests/hour by default, configurable per key at creation time.

Headers

Successful responses, 429s, and handler-level errors carry both the modern RateLimit-* headers and the legacy X-RateLimit-* aliases, so older integrations that only look for one or the other both work. 401, 402, and 403 are returned before the rate-limit check runs and carry neither — do not assume the headers are present on every response.

HeaderMeaning
RateLimit-LimitThe key's hourly limit.
RateLimit-RemainingRequests remaining in the current window.
RateLimit-ResetDelta seconds until the window resets (draft-spec style).
X-RateLimit-LimitSame as RateLimit-Limit (legacy alias).
X-RateLimit-RemainingSame as RateLimit-Remaining (legacy alias).
X-RateLimit-ResetUnix timestamp (seconds) when the window resets (legacy semantics — note this differs from RateLimit-Reset, which is a delta).
Retry-AfterPresent only on 429 responses. Delta seconds to wait before retrying.

Algorithm: sliding-window approximation

The limiter is DB-backed and dependency-free (no Redis/Upstash on this path) — durable across serverless instances, which an in-memory counter is not. It approximates a sliding window using two fixed 1-hour windows:

  1. Every request atomically increments a counter for the current hour-aligned window (`INSERT ... ON CONFLICT DO UPDATE SET count = count
    • 1`).
  2. The previous window's count is read and weighted by how much of it still "overlaps" the trailing hour: effective = currentCount + previousCount × (1 − elapsedFraction).
  3. The request is allowed if effective <= limit.

This is the same approximation Cloudflare and other edge rate limiters document publicly. It is slightly permissive right at window boundaries (a client bursting across the 59:59 → 00:00 mark can briefly exceed the nominal limit) — this is a known, accepted trade-off, not a security control. The per-key limit is a fairness/cost backstop, not an auth boundary.

The limiter fails open: if the rate-limit check itself errors (a transient DB blip), the request is allowed through and a warning is logged server-side. Availability wins over strict enforcement here — a broken limiter should never take down the public API.


Endpoint reference

All endpoints below share the auth, envelope, pagination, and rate-limit behavior documented above. Bodies are validated with Zod; a validation failure returns 400 validation_error with per-field details.

Responses are built by an explicit field allowlist per resource — a new database column is never exposed by accident. Internal identifiers (organizationId), soft-delete bookkeeping (deletedAt), contact PII on clients (contactEmail, contactPhone, notes), and comment author identity are withheld. See the per-resource notes below.

Polling for changes? Don't. If you want to react to new time logs or invoices, subscribe to webhooks instead — you get a signed POST within seconds and spend none of your rate-limit budget. Use the endpoints below to read history, not to detect change.

Tasks

MethodPathRequired scopeNotes
GET/api/v1/taskstasks:readList. Filters: status, assigneeId, customerId, search, limit, cursor.
POST/api/v1/taskstasks:writeCreate. Body: title (required, ≤200 chars), description, status, assigneeId, customerId, tags[], constellationId, subtasks[]. Returns 201.
GET/api/v1/tasks/:taskIdtasks:readFetch one. 404 if missing or belongs to another org.
PATCH/api/v1/tasks/:taskIdtasks:writePartial update — all body fields optional. Also accepts constellationId.
DELETE/api/v1/tasks/:taskIdtasks:deleteReturns { success: true, id }.
POST/api/v1/tasks/:taskId/subtaskstasks:writeAppend checklist items. Body: items: [{ title (≤500 chars), isCompleted? }], 1–100 per call. Returns 201 with the created items.

status is one of "todo" | "up_next" | "in_progress" | "done". search matches title and description. Soft-deleted tasks are always excluded and there is no opt-in — the response withholds deletedAt, so an included row would be indistinguishable from a live one.

Task responses embed subtasks, tags, and comments. Comment bodies are exposed; comment author identity is not (there is no /api/v1/users to resolve an id against, and author name/avatar are teammate PII).

Filing a task under a project (added 2026-07-31). constellationId on create/update files the task under a project. It requires both tasks:write and constellations:write — filing rewrites a project's board, so a tasks-only key gets 403; an id that is not a project in this organization gets 400 before anything is written. One field drives both surfaces on purpose: it sets the task's primaryConstellationId (what the Today queue reads) and inserts the board link (what the project page reads). Writing only one of them leaves a task that is invisible on the other. Re-filing an already-filed task is a no-op, not a duplicate.

There is no way to unfile a task through v1 — constellationId: null is rejected rather than silently accepted.

Checklists. Send subtasks: [{ title }] on create to get the task and its checklist in one request (≤100 items, created in array order), or POST /api/v1/tasks/:taskId/subtasks to append to an existing task. There is no subtask read endpoint: GET /api/v1/tasks/:taskId already returns subtasks.

If the task row is created but the project link or the checklist then fails, the response is 500 whose message and details[0].message carry the created task id — the task exists, so retry with PATCH /api/v1/tasks/:id and/or the subtasks endpoint rather than re-creating it. (The Neon HTTP driver has no transactions; this is the same "durable write first, then side effects" ordering the timer-stop endpoint uses.)

Time logs

MethodPathRequired scopeNotes
GET/api/v1/time-logstime-logs:readList. Filters: from, to, taskId, customerId, userId, isBillable, source, limit, cursor.

Newest first (startedAt descending). source is one of "manual" | "timer" | "imported"; isBillable is the string "true" or "false".

  • from / to require a full ISO instant. ?from=2026-07-01 returns 400 — date-only and offset-less local times are rejected. Use 2026-07-01T00:00:00Z.
  • Both bounds are 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.
  • A running timer produces no row. In-progress timers live in the separate active_timers table; only committed logs appear here. This is the single most common surprise — "today's hours are missing" is almost always a timer that hasn't been stopped.
  • durationSeconds is authoritative for billing and is not derivable. endedAt - startedAt !== durationSeconds is normal (idle discard, manual edits, rounding, a 12-hour clamp). endedAt is the wall-clock truth and is nullable; taskId and customerId are nullable too.
  • notes and clientSessionId are never exposed. There is no rate or money column on a time log at all — billing lives on the client.

Customers

MethodPathRequired scopeNotes
GET/api/v1/customerscustomers:readList. Filters: search, includeArchived, limit, cursor.
POST/api/v1/customerscustomers:writeCreate. Body: name (required), email, phone, company, notes, contactName, color, logoUrl. Returns 201.

search matches the client name or company. Archived clients are excluded by default; pass ?includeArchived=true to include them. The value must be the literal string true or false — ?includeArchived=1 returns 400. Responses carry archived / archivedAt, so a client you pulled with includeArchived can be told apart from an active one.

Billing fields (hourlyRateCents, fixedRateCents, currency, taxRateBps, roundingIncrementMinutes, roundingMode, paymentLinkUrl, billingType) are exposed under customers:read, because invoicing is the primary reason to integrate. Contact PII (contactEmail, contactPhone, notes) is not exposed, regardless of scope.

Constellations (projects)

Rendered as "Projects" in the Moonstreak UI; the schema-level name predates the rebrand.

MethodPathRequired scopeNotes
GET/api/v1/constellationsconstellations:readList. Filters: customerId, status, search, includeArchived, limit, cursor.
POST/api/v1/constellationsconstellations:writeCreate. Body: title (required), goal, customerId, status, dueDate. Returns 201.

status is one of "planning" | "in_progress" | "completed" — these are the only three states the column has.

Request vocabulary (changed 2026-07-14). title and goal are canonical: they are the schema's own names and what GET returns, so request and response are symmetric. name and description are still accepted as deprecated aliases — a body using them continues to return 201 — but new integrations should send title/goal. Previously name/description were accepted and then silently dropped, creating an untitled project.

Status vocabulary (changed 2026-07-14). active is accepted as a deprecated alias for in_progress. onHold now returns 400 — the column has no such state, so accepting it was a promise the database could not keep. Previously active and onHold reached Postgres as invalid enum inputs (a 500 on create, silently ignored on list), and in_progress — the only real in-flight status — was rejected with a 400.

Constellation responses expose archived / archivedAt and, like customers, exclude archived rows unless ?includeArchived=true. ownerId, retrospective, and boardLayout are withheld.

Example: create a task

curl -X POST https://your-domain.example/api/v1/tasks \
  -H "Authorization: Bearer $MOONSTREAK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title": "Follow up with client", "status": "todo", "tags": ["urgent"]}'
{
  "data": {
    "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
    "title": "Follow up with client",
    "description": null,
    "status": "todo",
    "assigneeId": null,
    "customerId": null,
    "primaryConstellationId": null,
    "loggedSeconds": 0,
    "billableSeconds": 0,
    "tags": ["urgent"],
    "subtasks": [],
    "comments": [],
    "createdAt": "2026-07-14T10:00:00.000Z",
    "updatedAt": "2026-07-14T10:00:00.000Z"
  },
  "meta": {
    "apiVersion": "v1",
    "timestamp": "2026-07-14T10:00:00.000Z",
    "rateLimit": { "limit": 1000, "remaining": 999, "reset": 1752487200 }
  }
}

For more — pagination loops, 429/402 handling, and an end-to-end monthly invoicing export — see the API cookbook.


Related

  • api-cookbook.md — copy-pasteable recipes for everything above.
  • webhooks.md — outbound event subscriptions and delivery.
  • ical-feed.md — the free read-only calendar feed.
  • claude-code-skill.md — the first-party Claude Code skill built on the task and project endpoints above.