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 Type | When |
|---|---|
entry.ingested | A new entry is created (manual UI, CSV import, or ingest API) |
entry.review.queued | Entry pushed into the accountant review queue |
entry.review.approved | Accountant approves the review |
entry.review.rejected | Accountant rejects the review |
entry.review.changes_requested | Accountant requests changes |
entry.document.requested | Reviewer asks for an additional document (BASIS / PAYMENT_PROOF / SUPPORTING / CREDIT_NOTE) |
entry.completeness.changed | The entry's isComplete flag flips |
entry.voided | Entry is voided (manual or via ingest voidEntry) |
accounting.sync.completed | A push to QuickBooks (or other connection) succeeds |
accounting.sync.failed | A 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:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Moonlight-Event-Id | Same as eventId for cheap dedupe |
X-Moonlight-Event-Type | Same as eventType for routing without parsing the body |
X-Moonlight-Timestamp | UTC seconds at signing time |
X-Moonlight-Signature | v1=<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
eventIdfor dedupe; anidempotencyStoreis 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 theretryPolicyfield 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.
| Method | Path | Description |
|---|---|---|
POST | /integrations/:integrationId/webhooks | Create a subscription. Response includes the plaintext secret once — store immediately. |
GET | /integrations/:integrationId/webhooks | List active subscriptions. |
PATCH | /integrations/:integrationId/webhooks/:webhookId | Toggle isActive, change event types, etc. |
DELETE | /integrations/:integrationId/webhooks/:webhookId | Remove a subscription. |
GET | /integrations/:integrationId/webhooks/:webhookId/deliveries | Inspect delivery history (filterable by status). |
POST | /integrations/:integrationId/webhooks/:webhookId/test | Send a synthetic event with the current secret. |
POST | /integrations/:integrationId/webhooks/:webhookId/deliveries/:deliveryId/redeliver | Re-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).