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.
| Method | Path | Notes |
|---|---|---|
GET | /api/app/webhooks | List the org's endpoints (secret included — admins need it to verify signatures). |
POST | /api/app/webhooks | Create. Body: { url, description?, eventTypes[] }. Returns 201. |
PATCH | /api/app/webhooks/:id | Update. Body: { url?, description?, eventTypes?, isActive? }. Setting isActive: true resets the auto-disable bookkeeping (consecutiveFailures, disabledAt). |
DELETE | /api/app/webhooks/:id | Delete. Deliveries cascade with it. |
GET | /api/app/webhooks/:id/deliveries | Paginated 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 type | Fired when |
|---|---|
time_log.created | A time log becomes durable — either a timer stop or an offline-sync commit. data.source tells you which. |
invoice.created | An 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:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Moonstreak-Signature | t=<unix>,v1=<hex hmac> — see verification below. |
X-Moonstreak-Event-Type | e.g. time_log.created (same as the body's type). |
X-Moonstreak-Event-Id | The shared idempotency key (same as the body's id). |
X-Moonstreak-Delivery-Id | Unique 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'sconsecutiveFailurescounter 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:
| Field | Meaning |
|---|---|
id | Delivery row ID (same value sent as X-Moonstreak-Delivery-Id). |
eventId | The event's idempotency key (same value sent as X-Moonstreak-Event-Id). |
eventType | e.g. "time_log.created". |
status | "pending" (queued/in-flight), "success", or "failed" (retries exhausted). |
attempts | Number of delivery attempts made so far. |
lastStatusCode | HTTP status of the most recent attempt, or null for a network-level failure. |
lastError | Truncated to ≤1KB — never assume it's the full response body. |
lastAttemptAt | Timestamp of the most recent attempt. |
completedAt | Set once the delivery reaches a terminal state (success or failed). |
createdAt | When 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.