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 theAuthorizationheader. 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:
- 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.mdfor why. - 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.
- Revoke: deleting a key from Settings → API keys immediately
deactivates it (
isActive = false). Revoked keys return401 unauthorizedon 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.
| Scope | Grants |
|---|---|
tasks:read | GET task endpoints |
tasks:write | POST / PATCH task endpoints |
tasks:delete | DELETE task endpoints |
tasks:* | all task actions |
time-logs:read | GET /api/v1/time-logs |
customers:read | GET customer endpoints |
customers:write | POST customer endpoints |
customers:* | all customer actions |
constellations:read | GET constellation (project) endpoints |
constellations:write | POST 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:deleteis 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 nocustomers:delete/constellations:deleteto grant — remove them from the app instead.There is deliberately no
time-logs:*. Its prefix would match a futuretime-logs:writeand 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 theRateLimit-*response headers instead, which are set on more paths (see rate limits).meta.rateLimit.resetis a unix timestamp — the same semantics asX-RateLimit-Reset, notRateLimit-Reset, which is a delta.pagination— present only on list endpoints.cursoris present only whenhasMoreistrue; 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 onmessage, which is a human sentence that may change.details— present for validation errors (one entry per invalid field) and for403 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
error | HTTP status | When |
|---|---|---|
validation_error | 400 | Request body/query failed Zod validation. |
unauthorized | 401 | Missing/malformed Authorization header, or an invalid/expired key. |
plan_required | 402 | The org's current plan doesn't grant API access (see gating above). |
forbidden | 403 | The key is valid but lacks a required permission scope. |
not_found | 404 | The requested resource doesn't exist (or belongs to another org). |
conflict | 409 | The request conflicts with existing state. |
rate_limit_exceeded | 429 | The key has exceeded its per-hour rate limit. See below. |
internal_error | 500 | Unexpected 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, default50.cursor— query param, optional. Omit for the first page; pass the previous response'spagination.cursorto 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 iscreatedAtfor tasks, customers, and constellations, andstartedAtfor time logs. Treat it as opaque — its internal shape may change. pagination.hasMoretells you whether to keep paging;pagination.cursoris only present whenhasMoreistrue(there's nothing to encode past the last page).hasMoreis exact: it comes from reading one row beyond the page, sofalsemeans there is genuinely nothing left andtruealways yields at least one more item.
Changed 2026-07-14 — a malformed or tampered cursor now returns
400 validation_errorwithdetails[0].field = "cursor". It was previously treated as "first page", which turned a corrupted cursor into an infinite loop for any client paginating untilhasMore === 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.
| Header | Meaning |
|---|---|
RateLimit-Limit | The key's hourly limit. |
RateLimit-Remaining | Requests remaining in the current window. |
RateLimit-Reset | Delta seconds until the window resets (draft-spec style). |
X-RateLimit-Limit | Same as RateLimit-Limit (legacy alias). |
X-RateLimit-Remaining | Same as RateLimit-Remaining (legacy alias). |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets (legacy semantics — note this differs from RateLimit-Reset, which is a delta). |
Retry-After | Present 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:
- Every request atomically increments a counter for the current
hour-aligned window (`INSERT ... ON CONFLICT DO UPDATE SET count = count
- 1`).
- The previous window's count is read and weighted by how much of it still
"overlaps" the trailing hour:
effective = currentCount + previousCount × (1 − elapsedFraction). - 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
| Method | Path | Required scope | Notes |
|---|---|---|---|
GET | /api/v1/tasks | tasks:read | List. Filters: status, assigneeId, customerId, search, limit, cursor. |
POST | /api/v1/tasks | tasks:write | Create. Body: title (required, ≤200 chars), description, status, assigneeId, customerId, tags[], constellationId, subtasks[]. Returns 201. |
GET | /api/v1/tasks/:taskId | tasks:read | Fetch one. 404 if missing or belongs to another org. |
PATCH | /api/v1/tasks/:taskId | tasks:write | Partial update — all body fields optional. Also accepts constellationId. |
DELETE | /api/v1/tasks/:taskId | tasks:delete | Returns { success: true, id }. |
POST | /api/v1/tasks/:taskId/subtasks | tasks:write | Append 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
| Method | Path | Required scope | Notes |
|---|---|---|---|
GET | /api/v1/time-logs | time-logs:read | List. 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/torequire a full ISO instant.?from=2026-07-01returns400— date-only and offset-less local times are rejected. Use2026-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 byto=...T23:55:00Z. - A running timer produces no row. In-progress timers live in the separate
active_timerstable; 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. durationSecondsis authoritative for billing and is not derivable.endedAt - startedAt !== durationSecondsis normal (idle discard, manual edits, rounding, a 12-hour clamp).endedAtis the wall-clock truth and is nullable;taskIdandcustomerIdare nullable too.notesandclientSessionIdare never exposed. There is no rate or money column on a time log at all — billing lives on the client.
Customers
| Method | Path | Required scope | Notes |
|---|---|---|---|
GET | /api/v1/customers | customers:read | List. Filters: search, includeArchived, limit, cursor. |
POST | /api/v1/customers | customers:write | Create. 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.
| Method | Path | Required scope | Notes |
|---|---|---|---|
GET | /api/v1/constellations | constellations:read | List. Filters: customerId, status, search, includeArchived, limit, cursor. |
POST | /api/v1/constellations | constellations:write | Create. 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.