Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

317 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PRD — Inbound Webhooks
- Slug: `2026-05-11-inbound-webhooks`
- Date: `2026-05-11`
- Status: Draft
## Summary
Add user-configurable inbound webhook endpoints to Alga PSA so MSPs can wire external systems (RMM alerts, accounting platforms, Zapier/n8n, custom integrations) into Alga without writing code. Each inbound webhook accepts authenticated HTTP requests, verifies them, dedupes replays, logs the delivery, and dispatches the payload to one of two handlers:
1. **Direct action** — invoke a canned operation (create ticket, update ticket by external ref, upsert asset, mark invoice paid) with field-level mapping from the payload.
2. **Workflow trigger** — start an Alga workflow with a normalized envelope containing the body, headers, and verification metadata.
Outbound webhooks already exist; this work surfaces both inbound and outbound under a unified Settings → Webhooks page.
## Problem
Today, the only way to move data *into* Alga from external systems is the public REST API or one of the **purpose-built** integrations (NinjaOne RMM, Tactical RMM, Tanium, Xero, QBO, Entra, Microsoft Graph, Google). Those bundled integrations work well for the systems they cover but don't help MSPs who:
- Use an RMM Alga doesn't have a bundled integration for (e.g. N-able, Auvik, Liongard, Kaseya, Pulseway, or in-house monitoring).
- Run custom internal tooling (scripts, dashboards, alerting glue) that emits JSON over HTTP.
- Use automation platforms (Zapier, n8n, Make) where the only thing required is a URL that accepts a webhook.
- Want a non-RMM signal to drive an Alga action (e.g. SIEM alert → ticket, build failure → ticket, payment gateway event → invoice paid for a payment processor we don't bundle).
The existing bundled integrations already use inbound webhook patterns internally (NinjaOne has `webhookHandler.ts` + `alertProcessor.ts` + `ticketCreator.ts`; Tactical RMM stores a webhook secret). This work generalizes the pattern: any MSP admin can wire any HTTP source to any Alga entity action or workflow without code.
Result: integration friction is the #1 reason MSPs report Alga "doesn't fit our stack."
## Goals
- MSP admins can create, edit, and delete inbound webhook configurations from Settings → Webhooks.
- Each inbound webhook has a unique URL, a configurable authentication scheme, and a handler (direct action OR workflow).
- Verified payloads are persisted, deduped, and visible in a delivery log with replay capability.
- **Direct-action coverage spans the major mutable entities** — tickets, clients, contacts, assets, invoices, time entries, project tasks, plus a cross-cutting tag action. An action **registry pattern** lets any `@alga-psa/*` package contribute additional actions without touching the inbound webhook core.
- External-ID lookup for "update by external reference" operations uses the **existing** `tenant_external_entity_mappings` table — no new per-entity `external_ref` columns.
- Workflow handlers receive a parsed, normalized envelope that includes raw body and headers, so workflow authors don't reimplement parsing.
- Field mapping reuses the existing workflow expression editor (JSONata + Monaco) — no new mapping UI is built from scratch.
- Asset upsert via inbound webhook reuses the existing `ingestNormalizedRmmDeviceSnapshot` pipeline (used by Tanium, NinjaOne, Tactical RMM) so device data flows through the same normalization path as bundled RMM integrations.
## Non-goals
- Per-source presets (preconfigured templates for specific RMM/accounting vendors). v1 ships generic config only; presets land in v2 once we see real-world payload shapes from custom configurations.
- Refactoring existing bundled integrations (NinjaOne, Tactical RMM, Tanium, Xero, QBO, Entra) to ride on the user-configurable system. They continue working as today. Consolidation is a separate v2+ effort.
- Visual drag-drop field mapper. JSONata text editor (already used in workflow editor) is the only mapping UX.
- Per-source normalizer shims. The "normalized envelope" is identity in v1: `{source: slug, body, headers, verified, delivery_id}`.
- Public Zapier/n8n/Make app listings in their directories. The inbound endpoint *enables* those integrations; building distributable apps is separate work.
- Outbound webhook feature changes beyond UI re-layout (tabbed under Settings → Webhooks).
- Multi-handler chains per webhook (run action AND workflow). One handler per webhook in v1.
- Client Portal webhook configuration. MSP portal only.
- Bidirectional sync semantics — inbound webhooks are one-way (external → Alga). Writing back to external systems is a separate outbound-webhook concern.
## Users and Primary Flows
**Primary persona:** MSP Admin (technical, comfortable with HTTP/JSON, may not be a developer).
**Flow A — Wire RMM alert to create a ticket (direct action):**
1. Admin navigates to Settings → Webhooks → Inbound tab → "New Webhook."
2. Names it ("ConnectWise Alerts"), picks auth method (HMAC-SHA256), generates a secret.
3. Picks handler type "Direct Action," selects "Create Ticket" from action dropdown.
4. UI renders the target field list for `createTicket` (title, description, priority, asset_id, external_ref, etc.). Each target field has an `ExpressionTextArea` for JSONata mapping.
5. Admin captures a sample payload by sending one test request to the URL (capture mode). The expression editor's autocomplete now suggests paths from the captured sample.
6. Admin maps `title``alert.message`, `priority``alert.severity`, `external_ref``alert.id`, etc.
7. Saves. Copies URL + secret into ConnectWise.
8. Real alert arrives → verified → mapped → ticket created. Delivery shows in the log.
**Flow B — Wire alert to a workflow (workflow handler):**
1. Same setup as A, but handler type "Workflow."
2. Admin picks an existing workflow from a dropdown (e.g. "Triage Critical Alert").
3. Payload arrives → workflow starts with `context.input = { source: "connectwise-alerts", body: {...}, headers: {...}, verified: true, delivery_id, idempotency_key }`.
4. Workflow branches, calls actions, creates tickets, pages on-call.
**Flow C — Debug a failed delivery:**
1. Admin opens delivery log row → sees full request body, headers, response, latency, error.
2. Clicks "Replay" → request is re-dispatched against current config (with current mapping/handler).
3. Replay shows as a new delivery row marked as a replay.
## UX / UI Notes
- Existing `AdminWebhooksSetup.tsx` (Settings → Security) becomes the **Outbound** tab in a new `AdminWebhooksSetup` containing two tabs (`Inbound` | `Outbound`). No regressions to outbound feature set.
- Inbound list view mirrors outbound: table with name, URL, handler type, last delivery, active state, kebab menu.
- Inbound create/edit dialog has sections: Identity (name, slug), Auth (method + config), Idempotency (key source), Handler (type + action/workflow + mapping), Active.
- Handler section is conditional:
- `Direct Action`: dropdown of supported actions; below that, the action's target fields rendered as labeled `ExpressionTextArea` rows. A side panel shows the captured sample payload tree for click-to-insert path.
- `Workflow`: dropdown of tenant's workflows; an info card explains the envelope shape the workflow receives.
- Sample payload capture: button "Capture sample request" toggles a 5-minute capture window. The first verified request lands in the editor as the schema source. Re-capture overwrites.
- Delivery log: identical shape to outbound delivery log (drawer with request/response, replay button).
- All strings via `t('translation.key')` (i18n requirement per CLAUDE.md).
- All interactive elements need `id` attributes (UI reflection requirement per CLAUDE.md).
## Requirements
### Functional Requirements
**Configuration:**
- F-CRUD inbound webhook configs: name, slug (URL-safe, unique per tenant), is_active, description.
- F-Auth schemes: HMAC-SHA256 (configurable signature header name + secret), Bearer token, IP allowlist (CIDR list), Shared-secret-in-path (`?token=`).
- F-Auth secrets stored in the secrets vault; never returned in API responses after creation.
- F-Idempotency key source: HTTP header name (e.g. `X-Idempotency-Key`) OR JSONata expression evaluated on the body (e.g. `alert.id`). Configurable per webhook.
- F-Duplicate detection: within a configurable window (default 24h), same idempotency key returns `200 OK` no-op without re-dispatching.
**Endpoint:**
- F-Catch-all route: `POST /api/inbound/[tenant_slug]/[webhook_slug]` (also accepts `PUT`, `PATCH` if the source needs it).
- F-Tenant slug resolution from URL → tenant context bootstrapped before further processing.
- F-Auth verification per config; rejected requests get `401` with no body details (avoid leaking config).
- F-Successful verification stores a delivery row before dispatch (so failed dispatches are still visible).
- F-Response: `200 OK` with `{delivery_id}` on accepted; `4xx` for auth/validation; `5xx` for internal dispatch failure.
**Direct Action Handler:**
- F-Action **registry pattern**: each `@alga-psa/*` package contributes inbound-callable actions via a typed registration (`name`, `entityType`, `targetFields[]`, `handle()`). Core lives in `server/src/lib/inboundWebhooks/actions/registry.ts`.
- F-V1 ships the following actions:
- **Tickets**: `createTicket`, `updateTicketByExternalId`, `addTicketCommentByExternalId`, `changeTicketStatusByExternalId`
- **Clients**: `upsertClientByExternalId`, `setClientActiveByExternalId`
- **Contacts (client_users)**: `upsertContactByExternalId`
- **Assets**: `upsertAssetByExternalId` — routes through existing `ingestNormalizedRmmDeviceSnapshot` from `@alga-psa/integrations/lib/rmm/sharedAssetIngestionService` when payload is flagged as an RMM device snapshot; otherwise plain asset upsert
- **Invoices**: `markInvoicePaidByExternalId`, `updateInvoiceStatusByExternalId`
- **Time Entries**: `createTimeEntry`
- **Project Tasks**: `createProjectTask`, `updateProjectTaskStatusByExternalId`
- **Cross-cutting**: `addTagToEntityByExternalId` (accepts target entity_type)
- F-Action selector dropdown in UI, grouped by entity type.
- F-Each action declares its target field list (typed: string, int, enum, ref-to-entity), with required/optional flags.
- F-Field mapping: each target field is a JSONata expression evaluated against the request body.
- F-`*ByExternalId` actions look up the target entity via `tenant_external_entity_mappings` using `integration_type = webhook_slug`, `alga_entity_type = <ticket|client|asset|...>`, `external_entity_id = <mapped value>`. **No new `external_ref` columns are added to entity tables.**
- F-Create actions (`createTicket`, `upsertClient*`, `upsertContact*`, `upsertAsset*`) MAY optionally write a mapping row when an `external_id` field is mapped — so subsequent webhooks can resolve back to the created entity.
- F-Lookup miss on `update*` / `addComment*` / `markPaid*` actions returns a failed delivery (not silent no-op).
- F-Validation errors (missing required mapped field, type mismatch) record a failed delivery with a clear error message.
**Workflow Handler:**
- F-Workflow selector dropdown (lists tenant's workflows).
- F-Payload envelope: `{ source: webhook_slug, body: parsed_json, headers: filtered_safe_headers, verified: true, delivery_id, idempotency_key, received_at }`.
- F-Workflow run is triggered with the envelope as `context.input`.
- F-Workflow failure does NOT mark inbound delivery failed; the delivery's job was to start the workflow, not complete it. Delivery log links to the workflow run for debugging.
**Field Mapping UX (reuse workflow expression editor):**
- F-Reuse `ExpressionTextArea` and `ExpressionEditor` from `ee/server/src/components/workflow-designer/`.
- F-New webhook payload context adapter (`webhookPayloadContextAdapter.ts`) that introspects a captured sample payload to feed autocomplete.
- F-Sample payload capture: a "capture next request" mode that stores the first verified body to the webhook config; the editor reads from this for autocomplete.
- F-Editor diagnostics: invalid JSONata path → inline warning. Type mismatch (e.g. mapping string to int field) → inline warning.
**Delivery Log:**
- F-Persist every verified request: request body, headers (filtered), response status, response body, latency, handler outcome, error message, retry count.
- F-Auth-rejected requests logged separately with limited detail (no body) to support abuse triage.
- F-List view filtered by webhook, date range, status.
- F-Detail drawer mirrors outbound's.
- F-Replay button re-dispatches against current config; result is a new delivery row tagged `is_replay=true`, `replayed_from=<original_id>`.
**Permissions:**
- F-New permission resource `inbound_webhook` with `create`, `read`, `update`, `delete`, `replay` actions.
- F-Outbound webhook permissions unchanged.
- F-`admin` role gets full inbound_webhook permissions by default.
### Non-functional Requirements
- N-Multi-tenant isolation: all queries scoped by `tenant`; slug uniqueness enforced per-tenant.
- N-Auth failures are constant-time where possible (HMAC compare via `crypto.timingSafeEqual`).
- N-Rate limit: per-webhook configurable limit (default 600/min), Redis-backed token bucket. Distinct from outbound rate limiter — share the bucket implementation, not the instance.
- N-Storage: raw delivery body retained 30 days, then truncated to a fixed-size head + metadata; status/idempotency keys retained longer for dedup integrity.
- N-Webhook URL slugs are URL-safe ASCII; tenant slug derives from existing tenant identifier (must already exist or we add one).
## Data / API / Integrations
### New tables
- `inbound_webhooks`
- `inbound_webhook_id` (pk, uuid), `tenant` (fk, **always in pk** per CitusDB rule), `name`, `slug`, `description`
- `auth_type` (enum: hmac_sha256, bearer, ip_allowlist, path_token)
- `auth_config` (jsonb: per-type — signature header, secret_vault_path, ip_cidrs, etc.)
- `idempotency_source` (jsonb: `{type: 'header'|'jsonata', value: string}`)
- `idempotency_window_seconds` (default 86400)
- `handler_type` (enum: direct_action, workflow)
- `handler_config` (jsonb: for direct_action `{action: string, field_mapping: {field: jsonata_expression}}`; for workflow `{workflow_id}`)
- `sample_payload` (jsonb, nullable — captured sample for autocomplete)
- `is_active`, `auto_disabled_at`, `created_at`, `updated_at`, `created_by`
- `inbound_webhook_deliveries`
- `delivery_id` (pk), `tenant` (in pk), `inbound_webhook_id` (fk)
- `idempotency_key` (nullable), `received_at`
- `request_method`, `request_path`, `request_headers` (jsonb, filtered), `request_body` (jsonb or text)
- `source_ip`, `user_agent`
- `auth_status` (verified | rejected_signature | rejected_bearer | rejected_ip | rejected_no_auth)
- `dispatch_status` (pending | dispatched | duplicate | failed)
- `handler_outcome` (jsonb: action result or workflow_run_id, error message)
- `response_status`, `response_body`, `duration_ms`
- `is_replay`, `replayed_from` (nullable fk)
### Reused existing tables
- **`tenant_external_entity_mappings`** (already exists; migration `20250502173321_create_tenant_external_entity_mappings.cjs`) is the canonical external-ID-to-Alga-entity lookup. The inbound webhook system uses `integration_type = <webhook_slug>` to namespace mappings per webhook config.
- Schema: `(tenant_id, integration_type, alga_entity_type, alga_entity_id, external_entity_id, external_realm_id, sync_status, last_synced_at, metadata)`.
- Unique indexes guarantee `(tenant_id, integration_type, alga_entity_type, alga_entity_id)` is one-to-one with the external side.
- **No new `external_ref` columns are added to `tickets`, `assets`, `invoices`, etc.** Lookups go through this table.
### Asset ingestion reuse
- `upsertAssetByExternalId` delegates to `ingestNormalizedRmmDeviceSnapshot` from `@alga-psa/integrations/lib/rmm/sharedAssetIngestionService` when the mapped payload conforms to the device-snapshot shape — same path used by Tanium, NinjaOne, and Tactical RMM. For non-device assets, a simpler `assets` upsert is used.
### Server actions (in `server/src/lib/actions/inboundWebhookActions.ts`)
- `listInboundWebhooks()`, `getInboundWebhook(id)`, `upsertInboundWebhook(input)`, `deleteInboundWebhook(id)`
- `rotateInboundWebhookSecret(id)`, `setInboundWebhookActiveState(id, active)`
- `listInboundDeliveries(filter, page)`, `getInboundDelivery(id)`, `replayInboundDelivery(id)`
- `captureSamplePayload(id)` (toggles capture mode), `clearSamplePayload(id)`
- `sendInboundWebhookTest(id, body, headers)` (synthetic request for UI testing)
- All wrapped in `withAuth` per CLAUDE.md pattern.
### Inbound HTTP route
- `server/src/app/api/inbound/[tenantSlug]/[webhookSlug]/route.ts`
- Handlers for `POST`, `PUT`, `PATCH`.
- Tenant resolution → config lookup → auth → idempotency → persist delivery → dispatch → respond.
### Public REST API and OpenAPI registration
The inbound webhook system has two API surfaces that must be registered in the OpenAPI spec at `server/src/lib/api/openapi/registry.ts` and a new file `server/src/lib/api/openapi/routes/inboundWebhooks.ts`:
**Management API** (mirrors outbound webhook API at `/api/v1/webhooks/*`):
- `GET /api/v1/inbound-webhooks` — list
- `POST /api/v1/inbound-webhooks` — create
- `GET /api/v1/inbound-webhooks/{id}` — read
- `PUT /api/v1/inbound-webhooks/{id}` — update
- `DELETE /api/v1/inbound-webhooks/{id}` — delete
- `POST /api/v1/inbound-webhooks/{id}/rotate-secret`
- `POST /api/v1/inbound-webhooks/{id}/test` — synthetic dispatch
- `POST /api/v1/inbound-webhooks/{id}/capture-sample` / `DELETE` — sample capture toggle
- `GET /api/v1/inbound-webhooks/{id}/deliveries` — delivery log list
- `GET /api/v1/inbound-webhooks/{id}/deliveries/{deliveryId}`
- `POST /api/v1/inbound-webhooks/{id}/deliveries/{deliveryId}/replay`
**Action discovery API:**
- `GET /api/v1/inbound-webhooks/actions` — returns the registered action set: `[{ name, entityType, displayName, description, targetFields: [{name, type, required, description, enumValues?}] }]`. Lets SDK clients and external tooling build mapping UIs without hardcoding the action list.
**Receiver endpoint:**
- `POST /api/inbound/{tenantSlug}/{webhookSlug}` — registered as a single templated entry. Body is `application/json` (per-webhook shape varies). Documented headers: signature header (configurable name), idempotency key header, content-type. Response codes: 200, 401, 409 (duplicate idempotency), 429, 4xx, 5xx. Response body: `{delivery_id}` on success.
**Schemas registered in OpenAPI components:**
- `InboundWebhookConfig`, `InboundWebhookCreateInput`, `InboundWebhookUpdateInput`
- `InboundWebhookAuthConfig` (discriminated union by `auth_type`)
- `InboundWebhookHandlerConfig` (discriminated union by `handler_type`)
- `InboundWebhookDelivery`
- `InboundActionDefinition`, `InboundActionTargetField`
- `WorkflowWebhookEnvelope` — the envelope shape workflow handlers receive (`{source, body, headers, verified, delivery_id, idempotency_key, received_at}`)
**Generation and validation:**
- After registration, `sdk/scripts/generate-openapi.ts` regenerates `alga-openapi.{ce,ee}.{yaml,json}`.
- New contract tests under `server/src/test/unit/api/` follow the `projectTasksOpenApi.contract.test.ts` pattern: assert handler types match the registered schema.
- Generated SDK clients (in `sdk/`) pick up the new endpoints on next generation.
### Action handler registry
- `server/src/lib/inboundWebhooks/actions/registry.ts` — central registry with `registerAction(def)` and `listActions()`.
- Each action exports `{ name, entityType, displayName, description, targetFields[], handle(ctx, mapped_values) }`.
- Actions live alongside the entity they target (e.g. `packages/tickets/src/actions/inboundActions.ts`, `packages/clients/src/actions/inboundActions.ts`) and call into the same internal handlers used by existing server actions. The registry imports each package's contributions at startup.
- UI dropdown is grouped by `entityType` and populated dynamically from the registry.
- Adding a new action in v2+ is a single file in the relevant package + a `registerAction()` call — no inbound webhook core changes needed.
### Workflow trigger integration
- Identify the workflow engine's external-trigger entrypoint (likely an event bus publish or direct `startWorkflow`).
- Implementation note: `webhookSubscriber.ts` pattern is for outbound only; inbound needs the reverse direction. Spike during implementation.
### Expression editor reuse
- Import `ExpressionTextArea` from `ee/server/src/components/workflow-designer/mapping/`.
- New context adapter at `shared/workflow/expression-authoring/adapters/webhookPayloadContextAdapter.ts` that returns the captured sample's path tree.
## Security / Permissions
- New permission `inbound_webhook` with `create | read | update | delete | replay`.
- HMAC verification uses `crypto.timingSafeEqual`.
- Bearer comparison also timing-safe.
- Auth secrets in vault, never returned in API responses (only at creation, one-time display).
- Auth-rejected requests log limited detail (no body) — separate retention so they can be purged faster.
- Sample payloads may contain PII (device names, financial data) — mark as sensitive; only retrievable by webhook owner; redact obvious patterns (credit card-like, email-like) on display? **Open question.**
- Slug enumeration: 401 responses identical for "unknown slug" vs "bad auth" to avoid leaking which webhooks exist.
## Rollout / Migration
- One migration creates `inbound_webhooks` and `inbound_webhook_deliveries` (Citus-distributed by `tenant`).
- Migrations to add `external_ref`/`external_id` columns are additive, nullable, indexed.
- New `inbound_webhook` permission seeded into the existing role-permission seed for `admin` role.
- Settings page UI changes are tabbed; existing outbound users see no behavior change, just a new tab.
- Feature flag `inbound_webhooks_enabled` (PostHog) to gate the Settings UI tab and the `/api/inbound/*` route. Default off for first release; enable per-tenant for early MSPs.
## Open Questions
1. **Workflow trigger entrypoint** — does the engine accept synchronous `startWorkflow(workflowId, input)` calls, or must inbound dispatch via an event bus publish? Reference existing inbound trigger paths in NinjaOne (`alertProcessor.ts`) which may already invoke workflows. Spike before sprint start.
2. **Tenant slug source** — do tenants already have a URL-safe slug, or do we need to add one? Affects URL stability.
3. **Sample payload PII redaction** — do we redact on display, on capture, or trust the admin? Lean toward "no redaction, mark sensitive, admin-only access."
4. **Mapping table integration_type namespace collisions** — bundled integrations write to `tenant_external_entity_mappings` with `integration_type` values like `'ninjaone'`, `'tactical_rmm'`. User-defined webhook slugs must not collide with these reserved values. Solution: prefix user slugs with `user:` (e.g. `user:my-monitor`) or maintain a reserved-name list. Decide before F032.
5. **Replay against current config vs original config** — replaying after the mapping has changed: re-evaluate with current mapping (current plan) or store the original mapping snapshot with the delivery? Current plan = simpler, may surprise users.
6. **Action registration order / discovery** — registry needs all package contributions loaded before the dropdown renders. Static imports vs dynamic discovery? Static is simpler; dynamic supports plugins later.
7. **Optional v1 action: `attachDocumentByExternalId`** — adding a note/document to an entity is common for "this alert details" use cases. Defer to v1.1 unless user requests.
8. **Consolidation with bundled integrations** — should NinjaOne / Tactical RMM eventually consume the same user-configurable webhook plumbing (with their secrets preset)? Out of scope for v1; flag as v2 candidate.
## Acceptance Criteria (Definition of Done)
1. MSP admin can create an inbound webhook via Settings → Webhooks → Inbound, receive a URL + secret, and POST an HMAC-signed JSON request → response `200 OK` with `delivery_id`.
2. Direct action `createTicket` with a JSONata field mapping creates a ticket whose fields match the mapped payload values; if an `external_id` is mapped, a row is written to `tenant_external_entity_mappings`.
3. `updateTicketByExternalId` resolves the ticket via `tenant_external_entity_mappings` (using `integration_type = webhook_slug`) and applies status / priority / assignment / board updates per mapping.
4. Workflow handler triggers a workflow run; workflow's `context.input` matches the documented envelope shape.
5. Duplicate idempotency keys within the window return `200 OK` without re-dispatching.
6. Delivery log shows request, response, status, latency; failed deliveries show error messages.
7. Replay button creates a new delivery linked to the original.
8. JSONata expression editor (reused from workflow designer) provides autocomplete against the captured sample payload.
9. Outbound webhook feature regression: all existing outbound tests pass; UI is now tabbed but functionality unchanged.
10. Auth: HMAC, Bearer, IP-allowlist, and path-token schemes each reject mismatched requests with `401` and accept matching ones.
11. Permissions: a user without `inbound_webhook:create` cannot create inbound webhooks via the UI or server action.
12. Citus rule compliance: every query includes `tenant`; new tables use composite PKs including `tenant`.
13. i18n: no hardcoded English strings in new UI components.
14. Feature flag `inbound_webhooks_enabled` correctly gates UI and route.
15. Action registry includes all v1 actions (13 actions across 8 entity types: ticket×4, client×2, contact×1, asset×1, invoice×2, time_entry×1, project_task×2, cross-cutting tag×1) and dropdown groups them by entity type.
16. `upsertAssetByExternalId` for RMM-shaped payloads delegates to `ingestNormalizedRmmDeviceSnapshot` and produces the same asset record shape as Tanium / NinjaOne / Tactical RMM ingestion.
17. Bundled integrations (NinjaOne, Tactical RMM, Tanium, Xero, QBO, Entra) continue to function unchanged — no regressions in their existing inbound webhook paths.
18. Reserved `integration_type` values from bundled integrations cannot be used as user webhook slugs (or user slugs are namespaced to avoid collision).
19. All `/api/v1/inbound-webhooks/*` management endpoints + the templated `/api/inbound/{tenantSlug}/{webhookSlug}` receiver endpoint are registered in the OpenAPI spec; generated `alga-openapi.{ce,ee}.{yaml,json}` files include them.
20. `GET /api/v1/inbound-webhooks/actions` returns the registered action set with target field schemas; SDK consumers can build mapping UIs from this response without hardcoding actions.
21. OpenAPI contract tests pass for every new management endpoint, asserting handler types match registered schemas.