Skip to main content

Outbound Webhooks

Moonlight pushes lifecycle events for entries and accounting to integration endpoints registered per workspace. The full event contract lives in the SDK at moonlight-sdk/src/events.ts and moonlight-sdk/src/webhook-handler.ts, so consumers should depend on @tkgreg/moonlight-sdk@^2.0.0 rather than re-implementing parsing or signature checks.

Event Types

Event TypeWhen
entry.ingestedA new entry is created (manual UI, CSV import, or ingest API)
entry.review.queuedEntry pushed into the accountant review queue
entry.review.approvedAccountant approves the review
entry.review.rejectedAccountant rejects the review
entry.review.changes_requestedAccountant requests changes
entry.document.requestedReviewer asks for an additional document (BASIS / PAYMENT_PROOF / SUPPORTING / CREDIT_NOTE)
entry.completeness.changedThe entry's isComplete flag flips
entry.voidedEntry is voided (manual or via ingest voidEntry)
accounting.sync.completedA push to QuickBooks (or other connection) succeeds
accounting.sync.failedA push fails after the configured retry budget

The exhaustive list also lives in WEBHOOK_EVENT_TYPES exported from the SDK.

Event Envelope

All events share the envelope shape:

{
"eventId": "0b8a71c2-…",
"eventType": "entry.review.approved",
"eventVersion": 1,
"occurredAt": "2026-05-05T08:30:00.000Z",
"workspaceId": 4,
"integrationId": "f4d7e9bb-…",
"entryId": 1234,
"entryExternalId": "neary:purchase:100",
"entryExternalSource": "neary",
"data": {
"reviewId": "55b73a78-…",
"reviewStatus": "APPROVED",
"reviewedBy": "user-42",
"reason": null
}
}

integrationId may be null for events that fan out to every integration in a workspace. entryId and entryExternalId are null for events that aren't entry-scoped (rare).

Headers

Each delivery carries:

HeaderDescription
Content-Typeapplication/json
X-Moonlight-Event-IdSame as eventId for cheap dedupe
X-Moonlight-Event-TypeSame as eventType for routing without parsing the body
X-Moonlight-TimestampUTC seconds at signing time
X-Moonlight-Signaturev1=<hex> HMAC-SHA256 of {timestamp}.{rawBody} using the webhook secret

Signature Scheme

signed_payload = `${X-Moonlight-Timestamp}.${rawBodyAsUtf8}`
expected_sig = HMAC_SHA256(secret, signed_payload).hex()
header_value = `v1=${expected_sig}`

Verify in constant time. Reject events outside a 300-second tolerance window. The SDK helpers (processWebhookRequest, createWebhookHandler, createFastifyWebhookHandler) implement this for you.

Delivery Semantics

  • At-least-once. Persist eventId for dedupe; an idempotencyStore is built into the SDK middleware.
  • Retry policy. Default: 8 attempts with backoff [1, 5, 30, 120, 600, 1800, 3600, 7200] seconds. Per-webhook overrides via the retryPolicy field at creation.
  • Status transitions: PENDING → DELIVERED | FAILED → DEAD. Dead deliveries can be replayed from the UI or the REST endpoint below.

Management API

Authenticated with a JWT and the integration.webhook.manage permission.

MethodPathDescription
POST/integrations/:integrationId/webhooksCreate a subscription. Response includes the plaintext secret once — store immediately.
GET/integrations/:integrationId/webhooksList active subscriptions.
PATCH/integrations/:integrationId/webhooks/:webhookIdToggle isActive, change event types, etc.
DELETE/integrations/:integrationId/webhooks/:webhookIdRemove a subscription.
GET/integrations/:integrationId/webhooks/:webhookId/deliveriesInspect delivery history (filterable by status).
POST/integrations/:integrationId/webhooks/:webhookId/testSend a synthetic event with the current secret.
POST/integrations/:integrationId/webhooks/:webhookId/deliveries/:deliveryId/redeliverRe-attempt a failed/dead delivery.

The SDK exposes thin wrappers (subscribeWebhook, listWebhooks, unsubscribeWebhook, testWebhook, redeliverWebhook).

SDK Quickstart

import express from 'express';
import {
createWebhookHandler,
type WebhookIdempotencyStore,
} from '@tkgreg/moonlight-sdk';

const idempotency: WebhookIdempotencyStore = {
async markSeen(eventId) {
// return true if first seen, false if duplicate
},
};

app.post(
'/webhooks/moonlight',
express.raw({ type: 'application/json' }),
createWebhookHandler({
secret: process.env.MOONLIGHT_WEBHOOK_SECRET!,
idempotencyStore: idempotency,
on: {
entryReviewApproved: async (event) => { /* ... */ },
accountingSyncFailed: async (event) => { /* ... */ },
},
}),
);

The handler returns 200 on success, 4xx on signature/timestamp problems (Moonlight will not retry), or 5xx on handler errors (Moonlight will retry per the policy).