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

Webhooks

Subscribe to events, verify signatures, and understand delivery and retries.

Last updated 2026-07-14
DOCSPublic API v1API CookbookWebhooksCalendar FeedClaude Code SkillON THIS PAGEEndpoint management APIEvent catalogDelivery requestVerifying signaturesIdempotencyRetry scheduleDelivery logRelated

Outbound Webhooks

Summary Lets an organization register HTTPS endpoints that receive signed, real-time notifications when domain events happen in Moonstreak — a time log is created, an invoice is generated. This is the mechanism Zapier (and future first-party integrations) triggers off of.

For the REST API these events complement (tasks/customers/constellations), see public-api-v1.md.


Endpoint management API

Webhook endpoints are managed from your Moonstreak session (not the public API) — the same withOrganizationAuthRequired pattern as every other in-app admin surface, admin role required. Creating an endpoint requires a plan with API access (customIntegrations), same gate as API keys.

MethodPathNotes
GET/api/app/webhooksList the org's endpoints (secret included — admins need it to verify signatures).
POST/api/app/webhooksCreate. Body: { url, description?, eventTypes[] }. Returns 201.
PATCH/api/app/webhooks/:idUpdate. Body: { url?, description?, eventTypes?, isActive? }. Setting isActive: true resets the auto-disable bookkeeping (consecutiveFailures, disabledAt).
DELETE/api/app/webhooks/:idDelete. Deliveries cascade with it.
GET/api/app/webhooks/:id/deliveriesPaginated delivery log (cursor pagination, limit ≤ 100).

Every mutation is scoped by (id AND organizationId) — one org can never read or modify another org's endpoint.

url must be https:// (an SSRF guard rejects loopback/private/link-local hosts and literal IPs in those ranges; http://localhost is permitted only when NODE_ENV=development, for local testing). eventTypes must be a non-empty array drawn from the event catalog below.

Example: register an endpoint

curl -X POST https://your-domain.example/api/app/webhooks \
  -H "Cookie: <session cookie>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.zapier.com/hooks/catch/12345/abcdef/",
    "description": "Zapier — new time logs",
    "eventTypes": ["time_log.created"]
  }'
{
  "id": "wh_abc123",
  "organizationId": "org_1",
  "url": "https://hooks.zapier.com/hooks/catch/12345/abcdef/",
  "secret": "whsec_9f3a...64 hex chars total",
  "description": "Zapier — new time logs",
  "eventTypes": ["time_log.created"],
  "isActive": true,
  "consecutiveFailures": 0,
  "createdAt": "2026-07-12T10:00:00.000Z"
}

Save the secret — it's needed to verify signatures on every delivery. There is no rotation endpoint in this build; rotate by deleting the endpoint and creating a new one.


Event catalog

Event typeFired when
time_log.createdA time log becomes durable — either a timer stop or an offline-sync commit. data.source tells you which.
invoice.createdAn invoice is generated (POST /api/app/invoices).

The catalog is deliberately small for this release; more event types (task.*, etc.) are a planned follow-up and won't change the delivery mechanics documented here.

To read historical time logs rather than be notified of new ones, use GET /api/v1/time-logs — the read counterpart of time_log.created. The webhook payload and the API resource describe the same underlying log. Reach for the API for backfill and reconciliation, and for webhooks to react to change; polling the API on a timer to detect new logs is the shape both of these exist to avoid.

Payload envelope

Every delivery's JSON body has the same wrapper:

{
  "id": "evt_9c1b2a3d-...",
  "type": "time_log.created",
  "createdAt": "2026-07-12T10:00:00.000Z",
  "data": { "...": "event-specific payload, see below" }
}

id is the idempotency key — see idempotency below.

time_log.created

Fires for both a live timer stop (source: "timer_stop") and an offline sync commit (source: "offline_sync") — every durably-created time log, not just ones over the in-app XP threshold.

{
  "id": "evt_9c1b2a3d-4e5f-6789-abcd-ef0123456789",
  "type": "time_log.created",
  "createdAt": "2026-07-12T10:00:00.000Z",
  "data": {
    "timeLog": {
      "id": "log_abc123",
      "taskId": "task_abc123",
      "description": "Client call + follow-up notes",
      "durationSeconds": 1800,
      "startedAt": "2026-07-12T09:30:00.000Z",
      "stoppedAt": "2026-07-12T10:00:00.000Z",
      "tags": ["billable", "client-acme"],
      "isBillable": true
    },
    "source": "timer_stop",
    "userId": "user_456",
    "organizationId": "org_1"
  }
}

source is "timer_stop" when the log was created by stopping a live timer, or "offline_sync" when it arrived through the offline outbox sync endpoint (POST /api/app/time-logs/sync) — useful if your integration treats retroactively-synced entries differently from live ones.

invoice.created

{
  "id": "evt_1a2b3c4d-5e6f-7890-abcd-ef1234567890",
  "type": "invoice.created",
  "createdAt": "2026-07-12T10:05:00.000Z",
  "data": {
    "invoice": {
      "id": "inv_abc123",
      "number": "INV-2026-0042",
      "customerId": "customer_789",
      "totalCents": 450000,
      "currency": "USD",
      "status": "draft",
      "periodStart": "2026-07-01T00:00:00.000Z",
      "periodEnd": "2026-07-31T23:59:59.000Z"
    },
    "organizationId": "org_1"
  }
}

Delivery request

Every delivery is an HTTP POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
X-Moonstreak-Signaturet=<unix>,v1=<hex hmac> — see verification below.
X-Moonstreak-Event-Typee.g. time_log.created (same as the body's type).
X-Moonstreak-Event-IdThe shared idempotency key (same as the body's id).
X-Moonstreak-Delivery-IdUnique per delivery attempt row — differs from the event ID when the same event fans out to multiple endpoints, but is stable across retries of the same delivery.

The request has a 10-second timeout and does not follow redirects (redirect: "error") — if your endpoint 3xx-redirects, the delivery is treated as failed, both as a defense against being bounced to an internal target and because a redirect changes what's actually receiving the signed payload.


Verifying signatures

The signature scheme is Stripe-style: a timestamp embedded in the signed material, so a captured payload can't be replayed indefinitely.

X-Moonstreak-Signature: t=1752314400,v1=5257a869e7bfa8...
  • t — unix seconds when the payload was signed.
  • v1=<hex> — HMAC-SHA256(secret, "${t}.${rawBody}"), lowercase hex.

To verify: recompute the HMAC over ${t}.${rawBody} using your endpoint's secret, compare it to v1 with a constant-time comparison, and reject the delivery if now - t exceeds your tolerance (300 seconds / 5 minutes is the reference tolerance verifyWebhookSignature uses server-side; match it unless you have a reason not to).

Use the raw, unparsed request body for the HMAC — re-serializing parsed JSON can produce different bytes (key order, whitespace) and break verification.

Node.js example

const crypto = require("node:crypto");

/**
 * @param secret     Your endpoint's `whsec_...` secret.
 * @param rawBody    The exact raw request body bytes/string (not re-serialized JSON).
 * @param header     The `X-Moonstreak-Signature` header value.
 * @param toleranceSeconds  Replay-protection window. Default 300s (5 min).
 */
function verifyMoonstreakSignature(
  secret,
  rawBody,
  header,
  toleranceSeconds = 300,
) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => {
      const idx = part.indexOf("=");
      return [part.slice(0, idx), part.slice(idx + 1)];
    }),
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1;
  if (!Number.isFinite(timestamp) || !signature) return false;

  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
    return false; // outside the replay-protection window
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const expectedBuf = Buffer.from(expected, "hex");
  const actualBuf = Buffer.from(signature, "hex");
  if (expectedBuf.length !== actualBuf.length) return false;

  return crypto.timingSafeEqual(expectedBuf, actualBuf);
}

// Express example:
app.post(
  "/webhooks/moonstreak",
  express.raw({ type: "application/json" }), // raw body, not pre-parsed JSON
  (req, res) => {
    const ok = verifyMoonstreakSignature(
      process.env.MOONSTREAK_WEBHOOK_SECRET,
      req.body.toString("utf8"),
      req.header("X-Moonstreak-Signature"),
    );
    if (!ok) return res.status(401).send("invalid signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // ... handle event.type / event.data, using event.id for idempotency ...
    res.status(200).send("ok");
  },
);

This exactly mirrors the signing implementation Moonstreak uses to produce the header — same HMAC construction, same 300-second default tolerance, same constant-time comparison.

Other languages

A Python port of this verifier — same construction, same tolerance, same constant-time compare, plus a Flask receiver that reads the raw body — lives in api-cookbook.md. The Node version above is the canonical one; port from it rather than from a paraphrase.


Idempotency

Deliver-at-least-once semantics apply: a retried delivery, or a delivery replayed after your endpoint returns a 2xx but the response is lost in transit, can arrive more than once. Use the payload's id field (also sent as X-Moonstreak-Event-Id) as your idempotency key — store processed event IDs and skip anything you've already handled. Note that id/Event-Id is shared across every endpoint an event fans out to; X-Moonstreak-Delivery-Id is the per-attempt row identifier if you need to distinguish delivery attempts specifically (e.g. for support/debugging correlation), but id is the correct key for de-duplicating the underlying event.


Retry schedule

A delivery attempt is retried until it succeeds (2xx response) or retries are exhausted:

  • 5 attempts total (1 initial + 4 retries), spaced by Inngest's default exponential backoff with jitter.
  • Any non-2xx status, a network error, or a timeout (10s) counts as a failed attempt and is retried, except 410 Gone — see below.
  • Once all 5 attempts are exhausted, the delivery is marked failed (terminal — "failed" here means retries were exhausted, not that a single attempt failed) and the endpoint's consecutiveFailures counter increments.

410 Gone — immediate disable

If your endpoint ever responds 410 Gone, Moonstreak treats that as "this endpoint is permanently gone" — the delivery is not retried, and the endpoint is disabled immediately (isActive: false, disabledAt set). This is the standard signal to use if you're decommissioning an integration and want in-flight/future deliveries to stop immediately rather than exhaust retries first.

Auto-disable after repeated failures

Independent of any single 410, an endpoint that racks up 20 consecutive exhausted deliveries (5-attempt failures in a row, no successes in between) is auto-disabled the same way. A single success resets the consecutive-failure counter to zero. Re-enable via PATCH /api/app/webhooks/:id with { "isActive": true } — this also resets consecutiveFailures and clears disabledAt so delivery resumes cleanly.


Delivery log

GET /api/app/webhooks/:id/deliveries returns the audit trail for an endpoint, newest first, cursor-paginated:

FieldMeaning
idDelivery row ID (same value sent as X-Moonstreak-Delivery-Id).
eventIdThe event's idempotency key (same value sent as X-Moonstreak-Event-Id).
eventTypee.g. "time_log.created".
status"pending" (queued/in-flight), "success", or "failed" (retries exhausted).
attemptsNumber of delivery attempts made so far.
lastStatusCodeHTTP status of the most recent attempt, or null for a network-level failure.
lastErrorTruncated to ≤1KB — never assume it's the full response body.
lastAttemptAtTimestamp of the most recent attempt.
completedAtSet once the delivery reaches a terminal state (success or failed).
createdAtWhen the delivery was queued (fan-out time, not send time).

The delivery log is always scoped to the requesting org — you can never see another org's payloads or endpoints, even by guessing an ID.


Related

  • public-api-v1.md — the REST API these events complement.
  • api-cookbook.md — recipes for that API, plus a Python port of the signature verifier below.
  • ical-feed.md — the read-only calendar feed, if you only need hours in a calendar.