diff --git a/.changeset/trim-webhook-dead-triggers.md b/.changeset/trim-webhook-dead-triggers.md
new file mode 100644
index 0000000000..4c898be4c7
--- /dev/null
+++ b/.changeset/trim-webhook-dead-triggers.md
@@ -0,0 +1,11 @@
+---
+"@objectstack/spec": patch
+"@objectstack/plugin-webhooks": patch
+---
+
+Trim the dead `undelete` and `api` webhook triggers (#3196). `WebhookTriggerType` declared five triggers but only three ever fired:
+
+- `undelete` had no event source — the engine has no soft-delete/restore capability (`delete` is a hard delete; no `deleted_at` convention, no restore operation, and `data.record.undeleted` is never emitted). The `undeleted` case in the auto-enqueuer's action mapper was dead code awaiting a producer that doesn't exist.
+- `api` ("manually triggered") had no fire path — the only webhook HTTP surface re-queues already-failed deliveries; nothing originates a manual fire.
+
+Both are removed from the enum (contract-first, matching #3184/#3195): authoring a webhook on a removed trigger now fails loudly at `os validate` / registration instead of registering a webhook that silently never fires. No shipped webhook metadata used either. The auto-enqueuer now also warns when a persisted `sys_webhook` row carries a trigger it can't map to an emitted record event (a drift-guard, so a dead trigger can't silently no-op again). Reintroduce `undelete` only alongside a real restore subsystem, and `api` only alongside a real manual-fire endpoint. Updated the `sys_webhook` trigger options, field help (all locales), docs, and reference; added rejection tests.
diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx
index b3791c6b1e..d0259518ac 100644
--- a/content/docs/automation/webhooks.mdx
+++ b/content/docs/automation/webhooks.mdx
@@ -93,8 +93,8 @@ in `definition_json`, a serialised `Webhook` JSON (canonical schema:
| `id` | text | Primary key. |
| `name` | text | Unique snake_case name — referenced in logs and audit. |
| `label` | text | Optional display label. |
-| `object_name` | text | Short object name whose events fire this webhook (blank = manual / API-triggered). |
-| `triggers` | text | Comma-separated event list: `create,update,delete,undelete,api`. |
+| `object_name` | text | Short object name whose record events fire this webhook. |
+| `triggers` | text | Comma-separated event list: `create,update,delete`. |
| `url` | text | External endpoint that receives the POST. |
| `method` | text | HTTP method. Default `POST`. |
| `description` | textarea | Free-text description. |
@@ -321,7 +321,7 @@ Notes:
- **`object`** — short object name the event came from.
- **`recordId`** — id of the affected record.
-- **`action`** — `created` / `updated` / `deleted` / `undeleted`.
+- **`action`** — `created` / `updated` / `deleted`.
- **`timestamp`** — event timestamp, an ISO 8601 string (passed through
unchanged from the originating realtime event's `timestamp` field — not
an epoch-ms number).
diff --git a/content/docs/references/automation/webhook.mdx b/content/docs/references/automation/webhook.mdx
index 385e53cade..1a1e8a2b8d 100644
--- a/content/docs/references/automation/webhook.mdx
+++ b/content/docs/references/automation/webhook.mdx
@@ -9,6 +9,36 @@ Webhook Trigger Event
When should this webhook fire?
+These mirror the record events the engine actually emits
+
+(`data.record.created` / `updated` / `deleted`), which the webhook
+
+auto-enqueuer maps to `create` / `update` / `delete`. Only events with a real
+
+producer are declared here — an author can't subscribe to something that
+
+never fires.
+
+Deliberately NOT triggers (#3196):
+
+- `undelete` — there is no soft-delete / restore capability in the engine
+
+(`delete` is a hard delete; no `deleted_at` convention, no restore
+
+operation, no `data.record.undeleted` emit), so it had no event source.
+
+Reintroduce it only alongside a real restore subsystem that emits an
+
+undelete event.
+
+- `api` (manual/programmatic fire) — no manual fire path exists (the only
+
+webhook HTTP surface re-queues already-failed deliveries). Reintroduce it
+
+with a real "fire this webhook now" endpoint/service, not as a bare enum
+
+value that silently never fires.
+
**Source:** `packages/spec/src/automation/webhook.zod.ts`
@@ -33,8 +63,8 @@ const result = Webhook.parse(data);
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Webhook unique name (lowercase snake_case) |
| **label** | `string` | optional | Human-readable webhook label |
-| **object** | `string` | optional | Object to listen to (optional for manual webhooks) |
-| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]` | optional | Events that trigger execution |
+| **object** | `string` | optional | Object whose record events (create/update/delete) trigger this webhook |
+| **triggers** | `Enum<'create' \| 'update' \| 'delete'>[]` | optional | Events that trigger execution |
| **url** | `string` | ✅ | External webhook endpoint URL |
| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | ✅ | HTTP method |
| **headers** | `Record` | optional | Custom HTTP headers |
@@ -75,8 +105,6 @@ const result = Webhook.parse(data);
* `create`
* `update`
* `delete`
-* `undelete`
-* `api`
---
diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx
index 0603e8c249..06087c43db 100644
--- a/content/docs/references/integration/connector.mdx
+++ b/content/docs/references/integration/connector.mdx
@@ -184,7 +184,7 @@ Circuit breaker configuration
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules |
-| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations |
+| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
@@ -310,7 +310,7 @@ Connector type
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules |
-| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations |
+| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
@@ -465,8 +465,8 @@ Synchronization strategy
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Webhook unique name (lowercase snake_case) |
| **label** | `string` | optional | Human-readable webhook label |
-| **object** | `string` | optional | Object to listen to (optional for manual webhooks) |
-| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]` | optional | Events that trigger execution |
+| **object** | `string` | optional | Object whose record events (create/update/delete) trigger this webhook |
+| **triggers** | `Enum<'create' \| 'update' \| 'delete'>[]` | optional | Events that trigger execution |
| **url** | `string` | ✅ | External webhook endpoint URL |
| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | ✅ | HTTP method |
| **headers** | `Record` | optional | Custom HTTP headers |
diff --git a/content/docs/references/integration/misc.mdx b/content/docs/references/integration/misc.mdx
index fbd749da18..52f3a09810 100644
--- a/content/docs/references/integration/misc.mdx
+++ b/content/docs/references/integration/misc.mdx
@@ -100,7 +100,7 @@ Message acknowledgment mode
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules |
-| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations |
+| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
@@ -307,7 +307,7 @@ File access pattern
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules |
-| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations |
+| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
@@ -415,7 +415,7 @@ File storage provider type
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules |
-| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations |
+| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
@@ -548,7 +548,7 @@ GitHub provider type
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules |
-| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations |
+| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
@@ -609,7 +609,7 @@ GitHub provider type
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules |
-| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations |
+| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
@@ -745,7 +745,7 @@ SaaS provider type
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules |
-| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations |
+| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
index 2e4a7afda2..b0d051620f 100644
--- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
+++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
@@ -292,6 +292,43 @@ describe('AutoEnqueuer', () => {
await ae.stop();
});
+ it('warns and ignores a legacy trigger the engine never emits (#3196)', async () => {
+ // A row authored before undelete/api were removed keeps its valid
+ // triggers; the dead ones are dropped with a warning (drift-guard).
+ const engine = new FakeEngine({
+ sys_webhook: [webhook({ triggers: 'create,undelete,api' })],
+ });
+ const realtime = new FakeRealtime();
+ const { enqueue, calls } = makeRecorder();
+ const warn = vi.fn();
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0, logger: { warn } });
+ await ae.start();
+
+ await realtime.publish(event('created', 'contact', { id: 'c-1' }));
+ await flush();
+
+ expect(calls).toHaveLength(1); // the valid `create` trigger still fires
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('undelete'),
+ expect.objectContaining({ unknown: expect.arrayContaining(['undelete', 'api']) }),
+ );
+ await ae.stop();
+ });
+
+ it('skips a webhook whose only triggers are removed values (#3196)', async () => {
+ const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: 'undelete,api' })] });
+ const realtime = new FakeRealtime();
+ const { enqueue, calls } = makeRecorder();
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
+ await ae.start();
+
+ await realtime.publish(event('created', 'contact', { id: 'c-1' }));
+ await flush();
+
+ expect(calls).toHaveLength(0);
+ await ae.stop();
+ });
+
it('self-heals the cache when sys_webhook changes', async () => {
const engine = new FakeEngine({ sys_webhook: [] });
const realtime = new FakeRealtime();
diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
index 6cd46fb545..33869b6a8b 100644
--- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
+++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
@@ -28,8 +28,8 @@ interface OptionalLogger {
interface CachedSubscription {
id: string;
name: string;
- objectName: string | undefined; // empty = matches all objects (manual-only is filtered out earlier)
- triggers: Set<'create' | 'update' | 'delete' | 'undelete'>;
+ objectName: string | undefined; // empty = matches all objects
+ triggers: Set<'create' | 'update' | 'delete'>;
url: string;
method?: string;
headers?: Record;
@@ -226,13 +226,26 @@ export class AutoEnqueuer {
triggerList = s.split(',');
}
}
+ const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);
+ // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an
+ // emitted record event — e.g. a legacy `sys_webhook` row authored with
+ // the now-removed `undelete`/`api` values, which would otherwise sit in
+ // the cache matching nothing. A loud drift-guard so a dead trigger can't
+ // silently no-op again.
+ const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));
+ if (unknown.length > 0) {
+ this.logger.warn?.(
+ `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +
+ `${unknown.join(', ')} — ignored. Dispatchable triggers: create, update, delete.`,
+ { id: row.id, unknown },
+ );
+ }
const triggers = new Set(
- triggerList
- .map((t) => t.trim().toLowerCase())
- .filter(Boolean) as Array<'create' | 'update' | 'delete' | 'undelete'>,
+ normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as Array<'create' | 'update' | 'delete'>,
);
if (triggers.size === 0) {
- // Manual-only webhook (no triggers) — skip auto-enqueue.
+ // No dispatchable triggers (or a manual-only webhook with none) —
+ // skip auto-enqueue.
return null;
}
@@ -278,7 +291,7 @@ export class AutoEnqueuer {
if (event.object === this.subscriptionsObject) return; // self-heal handles its own
const action = event.type.slice('data.record.'.length) as
- | 'created' | 'updated' | 'deleted' | 'undeleted' | string;
+ | 'created' | 'updated' | 'deleted' | string;
const trigger = mapActionToTrigger(action);
if (!trigger) return;
@@ -353,7 +366,7 @@ export class AutoEnqueuer {
function mapActionToTrigger(
action: string,
-): 'create' | 'update' | 'delete' | 'undelete' | null {
+): 'create' | 'update' | 'delete' | null {
switch (action) {
case 'created':
return 'create';
@@ -361,9 +374,10 @@ function mapActionToTrigger(
return 'update';
case 'deleted':
return 'delete';
- case 'undeleted':
- return 'undelete';
default:
return null;
}
}
+
+/** The trigger values the enqueuer can actually map from an emitted record event. */
+const DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet = new Set(['create', 'update', 'delete']);
diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts
index adbd35f624..cef1ec3917 100644
--- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts
+++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts
@@ -116,12 +116,12 @@ export const SysWebhook = ObjectSchema.create({
// Object picker (same widget as sys_sharing_rule) instead of a free-text
// machine name. Falls back to a text input when the widget is unavailable.
widget: 'object-ref',
- description: 'Short object name whose events fire this webhook (blank = manual / API-triggered)',
+ description: 'Short object name whose record events (create/update/delete) fire this webhook',
group: 'Definition',
}),
triggers: Field.select(
- ['create', 'update', 'delete', 'undelete', 'api'],
+ ['create', 'update', 'delete'],
{
label: 'Triggers',
required: false,
@@ -129,7 +129,7 @@ export const SysWebhook = ObjectSchema.create({
// an array; the auto-enqueuer parser also tolerates the legacy
// comma-separated / JSON-string forms so existing rows keep working.
multiple: true,
- description: 'Record events that fire this webhook (empty = manual / API-triggered)',
+ description: 'Record events that fire this webhook',
group: 'Definition',
},
),
diff --git a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts
index 319d903db4..6152db492d 100644
--- a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts
+++ b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts
@@ -26,11 +26,11 @@ export const enObjects: NonNullable = {
},
object_name: {
label: "Object",
- help: "Short object name whose events fire this webhook (blank = manual / API-triggered)"
+ help: "Short object name whose record events (create/update/delete) fire this webhook"
},
triggers: {
label: "Triggers",
- help: "Comma-separated event list: create,update,delete,undelete,api"
+ help: "Comma-separated event list: create,update,delete"
},
url: {
label: "Target URL",
diff --git a/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts
index 9edb94a505..e3649f7345 100644
--- a/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts
+++ b/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts
@@ -26,11 +26,11 @@ export const esESObjects: NonNullable = {
},
object_name: {
label: "Objeto",
- help: "Nombre corto del objeto cuyos eventos activan este Webhook (vacío = activación manual / API)."
+ help: "Nombre corto del objeto cuyos eventos de registro (create/update/delete) activan este Webhook."
},
triggers: {
label: "Desencadenantes",
- help: "Lista de eventos separada por comas: create,update,delete,undelete,api."
+ help: "Lista de eventos separada por comas: create,update,delete."
},
url: {
label: "URL de destino",
diff --git a/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts
index 9e0efc89c6..e4ab1ec05e 100644
--- a/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts
+++ b/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts
@@ -26,11 +26,11 @@ export const jaJPObjects: NonNullable = {
},
object_name: {
label: "オブジェクト",
- help: "このウェブフックを発火するイベントのオブジェクト短縮名(空白 = 手動 / API トリガー)"
+ help: "このウェブフックを発火するレコードイベント(create/update/delete)のオブジェクト短縮名"
},
triggers: {
label: "トリガー",
- help: "カンマ区切りのイベントリスト: create,update,delete,undelete,api"
+ help: "カンマ区切りのイベントリスト: create,update,delete"
},
url: {
label: "ターゲット URL",
diff --git a/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts
index 6be1736c13..5e4c489aa8 100644
--- a/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts
+++ b/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts
@@ -26,11 +26,11 @@ export const zhCNObjects: NonNullable = {
},
object_name: {
label: "对象",
- help: "触发该 Webhook 的短对象名(留空 = 手动 / API 触发)"
+ help: "触发该 Webhook 的记录事件(create/update/delete)的短对象名"
},
triggers: {
label: "触发器",
- help: "以逗号分隔的事件列表:create,update,delete,undelete,api"
+ help: "以逗号分隔的事件列表:create,update,delete"
},
url: {
label: "目标 URL",
diff --git a/packages/spec/src/automation/webhook.test.ts b/packages/spec/src/automation/webhook.test.ts
index b473dd6fe7..c5aa34a5a9 100644
--- a/packages/spec/src/automation/webhook.test.ts
+++ b/packages/spec/src/automation/webhook.test.ts
@@ -9,13 +9,21 @@ import {
describe('WebhookTriggerType', () => {
it('should accept valid trigger types', () => {
- const validTypes = ['create', 'update', 'delete', 'undelete', 'api'];
+ const validTypes = ['create', 'update', 'delete'];
validTypes.forEach(type => {
expect(() => WebhookTriggerType.parse(type)).not.toThrow();
});
});
+ it('should reject undelete / api — removed, no event source (#3196)', () => {
+ // `undelete` had no soft-delete/restore producer; `api` had no manual fire
+ // path. Removed rather than left as silent no-ops — authoring one now fails
+ // loudly instead of registering a webhook that never fires.
+ expect(() => WebhookTriggerType.parse('undelete')).toThrow();
+ expect(() => WebhookTriggerType.parse('api')).toThrow();
+ });
+
it('should reject invalid trigger types', () => {
expect(() => WebhookTriggerType.parse('insert')).toThrow();
expect(() => WebhookTriggerType.parse('modify')).toThrow();
@@ -121,11 +129,11 @@ describe('WebhookSchema', () => {
const webhook = WebhookSchema.parse({
name: 'multi_trigger_webhook',
object: 'account',
- triggers: ['create', 'update', 'delete', 'undelete'],
+ triggers: ['create', 'update', 'delete'],
url: 'https://example.com/webhook',
});
- expect(webhook.triggers).toHaveLength(4);
+ expect(webhook.triggers).toHaveLength(3);
});
it('should accept HMAC secret for signing', () => {
diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts
index 2b773180b3..bccfecf21b 100644
--- a/packages/spec/src/automation/webhook.zod.ts
+++ b/packages/spec/src/automation/webhook.zod.ts
@@ -6,14 +6,29 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
/**
* Webhook Trigger Event
* When should this webhook fire?
+ *
+ * These mirror the record events the engine actually emits
+ * (`data.record.created` / `updated` / `deleted`), which the webhook
+ * auto-enqueuer maps to `create` / `update` / `delete`. Only events with a real
+ * producer are declared here — an author can't subscribe to something that
+ * never fires.
+ *
+ * Deliberately NOT triggers (#3196):
+ * - `undelete` — there is no soft-delete / restore capability in the engine
+ * (`delete` is a hard delete; no `deleted_at` convention, no restore
+ * operation, no `data.record.undeleted` emit), so it had no event source.
+ * Reintroduce it only alongside a real restore subsystem that emits an
+ * undelete event.
+ * - `api` (manual/programmatic fire) — no manual fire path exists (the only
+ * webhook HTTP surface re-queues already-failed deliveries). Reintroduce it
+ * with a real "fire this webhook now" endpoint/service, not as a bare enum
+ * value that silently never fires.
*/
import { lazySchema } from '../shared/lazy-schema';
export const WebhookTriggerType = z.enum([
- 'create',
- 'update',
- 'delete',
- 'undelete',
- 'api' // Manually triggered
+ 'create',
+ 'update',
+ 'delete',
]);
/**
@@ -63,7 +78,7 @@ export const WebhookSchema = lazySchema(() => z.object({
label: z.string().optional().describe('Human-readable webhook label'),
/** Scope */
- object: z.string().optional().describe('Object to listen to (optional for manual webhooks)'),
+ object: z.string().optional().describe('Object whose record events (create/update/delete) trigger this webhook'),
triggers: z.array(WebhookTriggerType).optional().describe('Events that trigger execution'),
/** Target */