Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
27 KiB
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:
- 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.
- 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_mappingstable — no new per-entityexternal_refcolumns. - 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
ingestNormalizedRmmDeviceSnapshotpipeline (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):
- Admin navigates to Settings → Webhooks → Inbound tab → "New Webhook."
- Names it ("ConnectWise Alerts"), picks auth method (HMAC-SHA256), generates a secret.
- Picks handler type "Direct Action," selects "Create Ticket" from action dropdown.
- UI renders the target field list for
createTicket(title, description, priority, asset_id, external_ref, etc.). Each target field has anExpressionTextAreafor JSONata mapping. - 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.
- Admin maps
title←alert.message,priority←alert.severity,external_ref←alert.id, etc. - Saves. Copies URL + secret into ConnectWise.
- Real alert arrives → verified → mapped → ticket created. Delivery shows in the log.
Flow B — Wire alert to a workflow (workflow handler):
- Same setup as A, but handler type "Workflow."
- Admin picks an existing workflow from a dropdown (e.g. "Triage Critical Alert").
- Payload arrives → workflow starts with
context.input = { source: "connectwise-alerts", body: {...}, headers: {...}, verified: true, delivery_id, idempotency_key }. - Workflow branches, calls actions, creates tickets, pages on-call.
Flow C — Debug a failed delivery:
- Admin opens delivery log row → sees full request body, headers, response, latency, error.
- Clicks "Replay" → request is re-dispatched against current config (with current mapping/handler).
- 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 newAdminWebhooksSetupcontaining 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 labeledExpressionTextArearows. 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
idattributes (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 OKno-op without re-dispatching.
Endpoint:
- F-Catch-all route:
POST /api/inbound/[tenant_slug]/[webhook_slug](also acceptsPUT,PATCHif the source needs it). - F-Tenant slug resolution from URL → tenant context bootstrapped before further processing.
- F-Auth verification per config; rejected requests get
401with no body details (avoid leaking config). - F-Successful verification stores a delivery row before dispatch (so failed dispatches are still visible).
- F-Response:
200 OKwith{delivery_id}on accepted;4xxfor auth/validation;5xxfor 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 inserver/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 existingingestNormalizedRmmDeviceSnapshotfrom@alga-psa/integrations/lib/rmm/sharedAssetIngestionServicewhen 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)
- Tickets:
- 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-
*ByExternalIdactions look up the target entity viatenant_external_entity_mappingsusingintegration_type = webhook_slug,alga_entity_type = <ticket|client|asset|...>,external_entity_id = <mapped value>. No newexternal_refcolumns are added to entity tables. - F-Create actions (
createTicket,upsertClient*,upsertContact*,upsertAsset*) MAY optionally write a mapping row when anexternal_idfield 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
ExpressionTextAreaandExpressionEditorfromee/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_webhookwithcreate,read,update,delete,replayactions. - F-Outbound webhook permissions unchanged.
- F-
adminrole 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_webhooksinbound_webhook_id(pk, uuid),tenant(fk, always in pk per CitusDB rule),name,slug,descriptionauth_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_deliveriesdelivery_id(pk),tenant(in pk),inbound_webhook_id(fk)idempotency_key(nullable),received_atrequest_method,request_path,request_headers(jsonb, filtered),request_body(jsonb or text)source_ip,user_agentauth_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_msis_replay,replayed_from(nullable fk)
Reused existing tables
tenant_external_entity_mappings(already exists; migration20250502173321_create_tenant_external_entity_mappings.cjs) is the canonical external-ID-to-Alga-entity lookup. The inbound webhook system usesintegration_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_refcolumns are added totickets,assets,invoices, etc. Lookups go through this table.
Asset ingestion reuse
upsertAssetByExternalIddelegates toingestNormalizedRmmDeviceSnapshotfrom@alga-psa/integrations/lib/rmm/sharedAssetIngestionServicewhen the mapped payload conforms to the device-snapshot shape — same path used by Tanium, NinjaOne, and Tactical RMM. For non-device assets, a simplerassetsupsert 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
withAuthper 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— listPOST /api/v1/inbound-webhooks— createGET /api/v1/inbound-webhooks/{id}— readPUT /api/v1/inbound-webhooks/{id}— updateDELETE /api/v1/inbound-webhooks/{id}— deletePOST /api/v1/inbound-webhooks/{id}/rotate-secretPOST /api/v1/inbound-webhooks/{id}/test— synthetic dispatchPOST /api/v1/inbound-webhooks/{id}/capture-sample/DELETE— sample capture toggleGET /api/v1/inbound-webhooks/{id}/deliveries— delivery log listGET /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 isapplication/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,InboundWebhookUpdateInputInboundWebhookAuthConfig(discriminated union byauth_type)InboundWebhookHandlerConfig(discriminated union byhandler_type)InboundWebhookDeliveryInboundActionDefinition,InboundActionTargetFieldWorkflowWebhookEnvelope— 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.tsregeneratesalga-openapi.{ce,ee}.{yaml,json}. - New contract tests under
server/src/test/unit/api/follow theprojectTasksOpenApi.contract.test.tspattern: 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 withregisterAction(def)andlistActions().- 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
entityTypeand 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.tspattern is for outbound only; inbound needs the reverse direction. Spike during implementation.
Expression editor reuse
- Import
ExpressionTextAreafromee/server/src/components/workflow-designer/mapping/. - New context adapter at
shared/workflow/expression-authoring/adapters/webhookPayloadContextAdapter.tsthat returns the captured sample's path tree.
Security / Permissions
- New permission
inbound_webhookwithcreate | 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_webhooksandinbound_webhook_deliveries(Citus-distributed bytenant). - Migrations to add
external_ref/external_idcolumns are additive, nullable, indexed. - New
inbound_webhookpermission seeded into the existing role-permission seed foradminrole. - 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
- 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. - Tenant slug source — do tenants already have a URL-safe slug, or do we need to add one? Affects URL stability.
- Sample payload PII redaction — do we redact on display, on capture, or trust the admin? Lean toward "no redaction, mark sensitive, admin-only access."
- Mapping table integration_type namespace collisions — bundled integrations write to
tenant_external_entity_mappingswithintegration_typevalues like'ninjaone','tactical_rmm'. User-defined webhook slugs must not collide with these reserved values. Solution: prefix user slugs withuser:(e.g.user:my-monitor) or maintain a reserved-name list. Decide before F032. - 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.
- 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.
- 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. - 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)
- MSP admin can create an inbound webhook via Settings → Webhooks → Inbound, receive a URL + secret, and POST an HMAC-signed JSON request → response
200 OKwithdelivery_id. - Direct action
createTicketwith a JSONata field mapping creates a ticket whose fields match the mapped payload values; if anexternal_idis mapped, a row is written totenant_external_entity_mappings. updateTicketByExternalIdresolves the ticket viatenant_external_entity_mappings(usingintegration_type = webhook_slug) and applies status / priority / assignment / board updates per mapping.- Workflow handler triggers a workflow run; workflow's
context.inputmatches the documented envelope shape. - Duplicate idempotency keys within the window return
200 OKwithout re-dispatching. - Delivery log shows request, response, status, latency; failed deliveries show error messages.
- Replay button creates a new delivery linked to the original.
- JSONata expression editor (reused from workflow designer) provides autocomplete against the captured sample payload.
- Outbound webhook feature regression: all existing outbound tests pass; UI is now tabbed but functionality unchanged.
- Auth: HMAC, Bearer, IP-allowlist, and path-token schemes each reject mismatched requests with
401and accept matching ones. - Permissions: a user without
inbound_webhook:createcannot create inbound webhooks via the UI or server action. - Citus rule compliance: every query includes
tenant; new tables use composite PKs includingtenant. - i18n: no hardcoded English strings in new UI components.
- Feature flag
inbound_webhooks_enabledcorrectly gates UI and route. - 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.
upsertAssetByExternalIdfor RMM-shaped payloads delegates toingestNormalizedRmmDeviceSnapshotand produces the same asset record shape as Tanium / NinjaOne / Tactical RMM ingestion.- Bundled integrations (NinjaOne, Tactical RMM, Tanium, Xero, QBO, Entra) continue to function unchanged — no regressions in their existing inbound webhook paths.
- Reserved
integration_typevalues from bundled integrations cannot be used as user webhook slugs (or user slugs are namespaced to avoid collision). - All
/api/v1/inbound-webhooks/*management endpoints + the templated/api/inbound/{tenantSlug}/{webhookSlug}receiver endpoint are registered in the OpenAPI spec; generatedalga-openapi.{ce,ee}.{yaml,json}files include them. GET /api/v1/inbound-webhooks/actionsreturns the registered action set with target field schemas; SDK consumers can build mapping UIs from this response without hardcoding actions.- OpenAPI contract tests pass for every new management endpoint, asserting handler types match registered schemas.