# Triggers & ingress

The **triggers** plugin is NetScript's front door for the outside world. It turns external events — inbound webhooks, files dropped on disk, and cron schedules — into durable background work. A trigger handler does one job: receive an event and return a list of *actions*. The canonical action is `enqueueJob(...)`, which hands the payload to the [workers](https://rickylabs.github.io/netscript/background-processing/workers/) plugin so the heavy lifting happens off the request path. The HTTP call returns immediately; the job runs durably behind it.

![Trigger ingress flow: a webhook POST is verified by an HMAC check, persisted as an event, and dispatched to the handler which returns an enqueueJob action; a file-watch event is debounced and stabilised before reaching the same handler path.](https://rickylabs.github.io/netscript/netscript/assets/diagrams/trigger-ingress-flow.svg)

*Two ingress edges, one processor: webhook POST → HMAC verify → event store → handler → enqueueJob; file-watch → debounce + stability → handler. Both converge on the trigger processor, which retries and DLQs.*

This closes the continuous-app loop. A webhook lands on the triggers service, it `enqueueJob`s a workers job, that job publishes a saga message, and the [saga](https://rickylabs.github.io/netscript/durable-workflows/sagas/) advances a workflow — all from one inbound POST.

The failure modes ingress must absorb are mundane and expensive: a payment provider retries the same webhook five times because your handler took too long; a half-written CSV fires an import mid-copy on a network share; a cron host reboots through the 02:00 run. Each has a declared answer here — `idempotencyKey` collapses sender retries, `stabilityThreshold` waits out the half-written file, and `backfill` replays the missed fire. Because every trigger is a frozen definition rather than routing code, the registry surface can list what fires what — the same property that lets a coding agent enumerate an app's ingress edges before touching one.

> Typed oRPC contract, raw webhook ingress
>
> Like
>
> services
>
> ,
>
> workers
>
> , and
>
> sagas
>
> , the triggers API service serves a
>
> typed v1 oRPC contract
>
> for trigger and event introspection plus management — describing triggers, listing and fetching triggers and events, firing a trigger, testing a webhook, previewing a schedule, enabling/disabling a trigger, and subscribing to an SSE event stream. The one deliberate exception is the
>
> webhook ingress endpoint
>
> itself (
>
> POST /api/v1/webhooks/:triggerId
>
> , plus a legacy
>
> GET /api/v1/events
>
> ): it stays a
>
> raw, signature-verifying route
>
> , not an oRPC procedure, because it must verify an HMAC signature over the raw request bytes — incompatible with oRPC's Zod body parsing. That keeps it open to any third-party webhook sender, which has no NetScript typed client and just POSTs to the plain URL.

## What it is

A trigger is a **declarative event source** plus a handler. NetScript ships three authorable trigger builders from `@netscript/plugin-triggers-core/builders` — `defineWebhook` (inbound HTTP), `defineFileWatch` (filesystem events), and `defineScheduledTrigger` (cron). All three return a frozen *definition* the runtime walker discovers; all three share one handler contract — `(event, context) => Promise<TriggerActionResult[]>` — and one runtime processor that verifies, persists, retries, and dead-letters. Three more kinds (`queue`, `stream`, `manual`) are reserved in the type surface but not yet executable. The processing engine itself lives in [`@netscript/watchers`](https://rickylabs.github.io/netscript/reference/watchers/) (file watching) and the trigger processor/ingress runtime (`@netscript/plugin-triggers-core/runtime`).

## Learn / Do

[Learn — Ingest a webhook  Storefront Track A, rung 05: scaffold the triggers plugin, author a webhook, POST to :8093, and watch it enqueue a job — closing the continuous-app ladder.](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/05-shipping-webhook/)

[Do — Add a plugin  The how-to recipe for public plugin package install and the local netscript-dev contributor path for source-backed trigger samples.](https://rickylabs.github.io/netscript/netscript/orchestration-runtime/how-to/add-a-plugin/)

## Minimal example — a verified payment webhook

The 80% case: a webhook that verifies an HMAC signature, then fans the inbound payment event out to a single durable job. The handler returns immediately; the real work runs on the [workers](https://rickylabs.github.io/netscript/background-processing/workers/) plugin.

```ts
// plugins/triggers/payment-status-webhook.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

// The workers job this webhook will enqueue (authored in plugins/workers/jobs/).
const reconcilePaymentJob = {
  id: 'reconcile-payment' as JobDefinition<'reconcile-payment'>['id'],
  name: 'Reconcile Payment',
  topic: 'default',
} satisfies JobDefinition<'reconcile-payment'>;

// Inbound POST -> enqueue a workers job. Ingress verifies the HMAC signature
// (verifier: 'hmac-sha256' + secretEnv) BEFORE this handler ever runs, so by the
// time you see the event the sender is already proven.
export const paymentStatusWebhook = defineWebhook(
  (event) => Promise.resolve([
    enqueueJob(reconcilePaymentJob, {
      payload: { raw: event.payload.body },
      idempotencyKey: event.idempotencyKey, // de-dupe sender retries
      priority: 80,
    }),
  ]),
  {
    id: 'payment-status-webhook',
    path: 'payments/status',
    verifier: 'hmac-sha256',
    secretEnv: 'PAYMENT_WEBHOOK_SECRET',
    description: 'Verified payment-status webhook that enqueues reconciliation.',
    tags: ['webhook', 'payments', 'verified'],
  },
);

export default paymentStatusWebhook;
```

## Key types first — the webhook definition

`defineWebhook(handler, spec)` takes your handler and a static `WebhookSpec`. These are the **actual** fields the builder accepts (confirmed via `deno doc`), shown before any prose so you can read the contract at a glance.

**WebhookSpec — the second argument to defineWebhook(handler, spec)**

| Name | Type | Description |
| --- | --- | --- |
| `id` | `string (required)` | Stable identifier for the webhook definition, e.g. 'payment-status-webhook'. Branded internally as a WebhookId. |
| `path` | `string (required)` | Ingress sub-path the webhook answers on, e.g. 'payments/status'. Resolved as the :triggerId route segment under /api/v1/webhooks/. |
| `verifier` | `'hmac-sha256' \| 'memory' \| string (required)` | Verification strategy selector (WebhookVerifierKind). 'hmac-sha256' performs a real signature check; 'memory' is open (dev only); a custom string selects a verifier you wire via selectVerifier. |
| `secretEnv` | `string?` | Name of the env var holding the signing secret. The runtime reads it (resolveSecret) and hands it to the verifier — the secret is never inlined in the definition. |
| `description` | `string?` | Human-readable summary surfaced by inspect/registry tooling. |
| `tags` | `readonly string[]?` | Free-form labels for discovery/filtering, e.g. ['webhook','payments','verified']. |
| `metadata` | `Readonly>?` | Arbitrary structured metadata attached to the definition. |

The handler receives a `TriggerEvent<'webhook', WebhookTriggerPayload>`. The payload carries the captured HTTP request: `body` (parsed/raw), `headers`, `method` (always `"POST"`), `path`, and an optional `remoteAddr`. The event envelope also surfaces `idempotencyKey`, `traceparent`/`tracestate`, and `requestHeaders` — so de-duplicating sender retries is a one-liner, as in the example above.

## HMAC verification — proving the sender

`verifier: 'hmac-sha256'` activates the `HmacSha256WebhookVerifier` adapter from `@netscript/plugin-triggers-core/adapters`. Ingress hashes the raw request body with the secret (resolved from `secretEnv`) and compares it against the signature header *before* persisting the event or running your handler — a bad signature is rejected at the edge. The `'memory'` verifier (`MemoryWebhookVerifier`) accepts everything and is for local iteration only.

**HmacSha256WebhookVerifier — constructor options (from @netscript/plugin-triggers-core/adapters)**

| Name | Type | Description |
| --- | --- | --- |
| `secret` | `string?` | Signing secret. Usually supplied by the runtime from the definition's secretEnv rather than hard-coded here. |
| `signatureHeader` | `string?` | Header carrying the sender's HMAC signature to compare against (e.g. 'x-signature'). |
| `idempotencyHeader` | `string?` | Header whose value is surfaced as the event idempotencyKey when present, so sender retries collapse. |

> How the verifier is wired
>
> You rarely construct the adapter yourself. The webhook spec declares
>
> verifier: 'hmac-sha256'
>
> and
>
> secretEnv
>
> , and the ingress runtime (
>
> createTriggerIngress
>
> ) resolves the secret and selects the verifier per definition (its
>
> verifier
>
> ,
>
> selectVerifier
>
> , and
>
> resolveSecret
>
> options). The verifier's
>
> verify(request)
>
> returns
>
> { ok, idempotencyKey?, reason? }
>
> —
>
> ok: false
>
> stops the event at the edge.

## File-watch triggers — react to dropped files

`defineFileWatch(handler, spec)` turns filesystem events into the same durable-work pipeline. The watching primitive lives in [`@netscript/watchers`](https://rickylabs.github.io/netscript/reference/watchers/); the trigger builder wraps it so a dropped file becomes a handler invocation that returns `enqueueJob` actions. Below: a CSV drop-folder that enqueues one import job per stable file.

```ts
// plugins/triggers/product-import.ts
import { defineFileWatch, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

const importProductsJob = {
  id: 'import-products' as JobDefinition<'import-products'>['id'],
  name: 'Import Products',
  topic: 'default',
} satisfies JobDefinition<'import-products'>;

// Fires once a *.csv lands in ./incoming/products and stops growing. The
// stabilityThreshold guards against half-written files on a network share:
// the watcher waits for 3 unchanged checks 1s apart before yielding the event.
export const productImportWatch = defineFileWatch(
  (event) => Promise.resolve([
    enqueueJob(importProductsJob, {
      payload: { file: event.payload.path },
      idempotencyKey: event.payload.path, // one import per file path
      priority: 50,
    }),
  ]),
  {
    id: 'product-import-watch',
    paths: ['./incoming/products'],
    patterns: ['*.csv'],
    on: ['create', 'modify'],
    stabilityThreshold: { checkIntervalMs: 1000, stableChecks: 3 },
    description: 'Imports a product CSV the moment it lands and stabilises.',
    tags: ['file-watch', 'erp', 'import'],
  },
);

export default productImportWatch;
```

**FileWatchSpec — the second argument to defineFileWatch(handler, spec)**

| Name | Type | Description |
| --- | --- | --- |
| `id` | `string (required)` | Stable identifier for the file-watch definition. |
| `paths` | `readonly string[] (required)` | Directories to watch (at least one). Relative paths resolve against the process cwd. |
| `patterns` | `readonly string[] (required)` | Glob patterns selecting which filenames fire, e.g. ['*.csv','sales_*.xlsx']. |
| `on` | `readonly ('create'\|'modify'\|'remove')[] (required)` | Which filesystem lifecycle events yield to the handler (FileWatchLifecycle). |
| `ignored` | `readonly string[]?` | Glob patterns to exclude from the watch. |
| `debounceMs` | `number?` | Per-file debounce window collapsing rapid successive events for the same path. |
| `stabilityThreshold` | `{ checkIntervalMs, stableChecks }?` | Network-FS tolerant gate: waits for stableChecks unchanged polls (checkIntervalMs apart) so half-written files don't fire early. |
| `description` | `string?` | Human-readable summary for tooling. |
| `tags` | `readonly string[]?` | Free-form discovery labels. |
| `metadata` | `Readonly>?` | Arbitrary structured metadata. |

The handler receives a `TriggerEvent<'file-watch', FileWatchTriggerPayload>`; the payload carries `path`, `kind` (the lifecycle event), and — when the watcher could read them — `size`, `modifiedAt`, and `stableChecks`.

### The underlying watcher

For lower-level or standalone file watching (outside the trigger pipeline), call `createWatcher(options)` from `@netscript/watchers` directly. It returns a `FileWatcher` whose `watch()` is an async generator of `WatchEvent`s.

```ts
// scripts/watch-incoming.ts
import { createWatcher } from "@netscript/watchers";

const watcher = createWatcher({
  paths: ["./incoming"],
  patterns: ["*.csv"],
  events: ["create", "modify"],
  stabilityThreshold: { checkIntervalMs: 1000, stableChecks: 3 },
});

for await (const event of watcher.watch()) {
  console.log(`${event.kind}: ${event.path}`);
}
```

**createWatcher(options) — WatcherOptions (from @netscript/watchers)**

| Name | Type | Description |
| --- | --- | --- |
| `paths` | `readonly string[] (required)` | Directories to watch (at least one required). |
| `patterns` | `readonly string[]?` | Glob patterns for filtering files. Default ['*']. |
| `events` | `readonly EventKind[]?` | Which FS events to yield ('create'\|'modify'\|'remove'). Default ['create']. |
| `debounceMs` | `number?` | Per-file debounce in milliseconds. Default 2000. |
| `contentHash` | `boolean?` | Compute a SHA-256 content hash for de-duplication. Default true. |
| `processExisting` | `boolean?` | Scan existing files on startup and emit them as create events. Default false. |
| `forcePolling` | `boolean?` | Force the polling strategy instead of native FS notifications. Default false (use for network paths). |
| `pollIntervalMs` | `number?` | Polling interval for the polling strategy. Minimum 500. Default 5000. |
| `minFileSize` | `number?` | Skip files smaller than this many bytes. Default 0. |
| `maxFileAge` | `number?` | Skip files older than this (ms); only applies during the startup scan. |
| `stabilityThreshold` | `StabilityOptions?` | When set, waits for files to stop growing before yielding (network-FS tolerant). |
| `signal` | `AbortSignal?` | Abort signal for graceful shutdown of the watch loop. |

The watcher selects the strategy for you: native OS notifications for local paths, polling for network paths or when `forcePolling: true`, and a `HybridStrategy` that blends both. Events pass a filter pipeline before you see them — `GlobFilter` limits filenames, `StabilityFilter` waits for files to stop growing, and `DedupFilter` skips repeated content hashes within its window. The concrete `NativeStrategy`, `PollingStrategy`, and `HybridStrategy` classes are internal; construct watchers with `createWatcher` or `new FileWatcher(...)`.

## Scheduled triggers — cron without a daemon

`defineScheduledTrigger(handler, spec)` fires a handler on a cron schedule. The spec combines discovery metadata (`id`, `description`, `tags`, `metadata`) with the cron `ScheduledTriggerSpec`. The scheduler runs inside the trigger processor entrypoint, not the Hono API service.

```ts
// plugins/triggers/nightly-reconcile.ts
import { defineScheduledTrigger, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

const nightlyReconcileJob = {
  id: 'nightly-reconcile' as JobDefinition<'nightly-reconcile'>['id'],
  name: 'Nightly Reconcile',
  topic: 'default',
} satisfies JobDefinition<'nightly-reconcile'>;

// Runs at 02:00 UTC. backfill replays fires the scheduler missed while down.
export const nightlyReconcile = defineScheduledTrigger(
  () => Promise.resolve([enqueueJob(nightlyReconcileJob, { payload: {} })]),
  {
    id: 'nightly-reconcile',
    description: 'Nightly reconciliation sweep.',
    cron: '0 2 * * *',
    timezone: 'UTC',
    persistent: true,
    backfill: { enabled: true, windowMs: 86_400_000, policy: 'fire-once' },
  },
);

export default nightlyReconcile;
```

**Scheduled trigger spec — DefineScheduledTriggerSpec + ScheduledTriggerSpec**

| Name | Type | Description |
| --- | --- | --- |
| `id` | `string (required)` | Stable identifier for the scheduled trigger definition. |
| `cron` | `CronExpression (required)` | Cron expression governing when the handler fires, e.g. '0 2 * * *'. |
| `timezone` | `string?` | IANA timezone the cron expression is evaluated in. Defaults to the runtime's zone. |
| `persistent` | `boolean?` | Whether the schedule state survives restarts so missed fires can be reasoned about. |
| `backfill` | `TriggerBackfillSpec?` | Quartz-style misfire handling: { enabled, windowMs, policy: 'fire-now'\|'fire-once'\|'do-nothing', maxMissedFires? } — replays fires the scheduler missed while down. |
| `description` | `string?` | Human-readable summary for tooling. |
| `tags` | `readonly string[]?` | Free-form discovery labels. |
| `metadata` | `Readonly>?` | Arbitrary structured metadata. |

## Supported trigger actions

Every trigger handler returns **actions** — declarative descriptions of what should happen after the event is accepted. The runtime processor reads each action and dispatches it. Exactly one action is wired end-to-end today; a second is *defined in the type surface* but not executable, and it now **fails loud** rather than silently dropping.

**Trigger actions — what the runtime processor actually does**

| Name | Type | Description |
| --- | --- | --- |
| `enqueueJob(job, opts)` | `✅ live` | Hands the payload to the workers plugin. The supported, end-to-end path — it closes the continuous-app loop (event → job → saga). opts: { payload?, idempotencyKey?, concurrencyKey?, priority? }. |
| `defer(...)` | `⛔ unsupported` | Defined in the action union (DeferAction) but NOT executable. The processor throws an unsupportedOperation error and routes the message to the dead-letter queue (DLQ). There is no deferred replay — do not author a trigger that relies on defer. |

> defer is defined-but-unsupported — it fails loud
>
> The
>
> defer
>
> action exists in the trigger action union, but the runtime processor does
>
> not
>
> implement deferred replay. When a handler emits a
>
> defer
>
> action, the processor throws an
>
> unsupportedOperation
>
> error and routes the message to the
>
> dead-letter queue (DLQ)
>
> — it does not silently swallow it. Build ingress flows on
>
> enqueueJob
>
> only; if you need delayed work, schedule it on the
>
> workers
>
> plugin from the enqueued job, or use a
>
> defineScheduledTrigger
>
> cron rather than deferring at the trigger edge.

## Runtime — ingress, processor, retry

The trigger runtime (`@netscript/plugin-triggers-core/runtime`) assembles the pieces the API service and background processor use. You configure these when wiring a custom host; the scaffold wires them for you.

**Trigger runtime entry points (from @netscript/plugin-triggers-core/runtime)**

| Name | Type | Description |
| --- | --- | --- |
| `createTriggerIngress(options)` | `→ TriggerIngressPort` | Builds the webhook ingress edge. Options: { definitions, eventStore, processor, verifier, selectVerifier?, resolveSecret?, logger?, now?, createEventId? }. Verifies + persists, then hands off to the processor; responds 202 Accepted. |
| `createTriggerProcessor(options)` | `→ TriggerProcessor` | Builds the processor that runs handlers and dispatches actions. Options: { idempotency, dlq, dispatchAction?, logger?, now?, random? }. Applies the retry policy and routes exhausted/unsupported events to the DLQ. |
| `defaultRetryPolicy()` | `→ TriggerRetryPolicy` | The default { maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier, jitter } policy applied before DLQ handoff. Override per definition via the trigger's retry field. |

> Where the background work runs
>
> The triggers API service (port
>
> 8093
>
> ) is the HTTP edge — it hosts
>
> createTriggerIngress
>
> behind a raw Hono router. The actual trigger processing — cron, file-watch, and DLQ handling — runs in a separate background processor entrypoint at
>
> plugins/triggers/src/runtime/trigger-processor.ts
>
> . Aspire wires both as resources (
>
> triggers-api
>
> and
>
> trigger-processor
>
> ) when you
>
> aspire start
>
> .

## Endpoints & port

The triggers API service runs on **`:8093`**. The webhook router resolves the inbound `:triggerId` segment against your registered `defineWebhook` definitions — so a definition with `path: 'payments/status'` is reachable at `POST :8093/api/v1/webhooks/payments/status`.

**Triggers API — runtime surface (port 8093, Hono)**

| Name | Type | Description |
| --- | --- | --- |
| `POST /api/v1/webhooks/:triggerId` | `Hono route` | Generic dispatch — :triggerId matches the path of any registered defineWebhook definition. Verifies, persists, responds 202 Accepted. |
| `POST /api/v1/webhooks/inbound/generic` | `Hono route` | The scaffold's generic inbound webhook. Posting JSON here resolves trigger id 'inbound/generic' and runs its actions. |
| `GET /api/v1/events?limit=10` | `Hono route` | Recent ingress events recorded by the event store. |
| `GET /health` | `Hono route` | Liveness check for the triggers API service. |

## Author + call a trigger

The simple case is one webhook that fans an inbound request out to a single job. The advanced case validates the payload first, then enqueues. The simple case is adapted from the scaffold's `plugins/triggers/generic-webhook.ts` sample and compiles as-is; the advanced tab is an illustrative pattern, not scaffold code — the real `plugins/triggers/webhook-validate-data.ts` is an accept-and-drop sample (`() => Promise.resolve([])`) that imports no zod and enqueues no job. Every handler returns an array of `enqueueJob` actions — the only supported trigger action.

> ns-workers is a shorthand you install once
>
> ns-workers
>
> is a name
>
> you
>
> give the workers plugin's CLI — the scaffold does not create it. Install it once, globally, and the
>
> ns-workers
>
> command below works as written:
>
> ```bash
> deno install -gArf -n ns-workers jsr:@netscript/plugin-workers@0.0.1-beta.11/cli
> ```
>
> Rather not install it?
>
> ns-workers <verb …>
>
> is exactly
>
> deno x -A jsr:@netscript/plugin-workers@0.0.1-beta.11/cli <verb …>
>
> — run that full form instead.

```ts
// plugins/triggers/generic-webhook.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

// A reference to the workers job this webhook will enqueue.
const healthCheckJob = {
  id: 'workers-plugin-health-check' as JobDefinition<'workers-plugin-health-check'>['id'],
  name: 'Workers Health Check',
  topic: 'default',
} satisfies JobDefinition<'workers-plugin-health-check'>;

// Inbound POST -> enqueue a workers job. The HTTP call returns immediately;
// the job runs durably on the workers plugin (:8091).
export const genericInboundWebhook = defineWebhook(
  () => Promise.resolve([
    enqueueJob(healthCheckJob, { payload: { verbose: false }, priority: 50 }),
  ]),
  {
    id: 'generic-inbound-webhook',
    path: 'inbound/generic',
    verifier: 'memory',
    description: 'Open webhook that enqueues the workers plugin health-check job.',
    tags: ['webhook', 'runtime-task', 'health-check'],
  },
);

export default genericInboundWebhook;
```

```ts
// plugins/triggers/webhook-validate-data.ts (pattern)
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';
import { z } from 'zod';

// Validate the inbound body before doing any work.
const InboundSchema = z.object({
  userId: z.string().min(1),
  source: z.string().default('webhook'),
});

const createUserSettingsJob = {
  id: 'create-user-settings' as JobDefinition<'create-user-settings'>['id'],
  name: 'Create User Settings',
  topic: 'default',
} satisfies JobDefinition<'create-user-settings'>;

export const validatedInboundWebhook = defineWebhook(
  (event) => {
    // Parse + validate. On a bad shape, accept-and-drop (return []),
    // or throw to reject the request.
    const parsed = InboundSchema.safeParse(event.payload.body ?? {});
    if (!parsed.success) return Promise.resolve([]);

    // Hand the validated payload to a workers job.
    return Promise.resolve([
      enqueueJob(createUserSettingsJob, {
        payload: { userId: parsed.data.userId },
        priority: 80,
      }),
    ]);
  },
  {
    id: 'validated-inbound-webhook',
    path: 'validate/data',
    verifier: 'memory',
    description: 'Validates the body with zod, then enqueues create-user-settings.',
    tags: ['webhook', 'validated'],
  },
);

export default validatedInboundWebhook;
```

```bash
# Triggers API runs on :8093. POST to the webhook's resolved path.
curl -X POST http://localhost:8093/api/v1/webhooks/inbound/generic \
  -H 'content-type: application/json' \
  -d '{"verbose": false}'

# Watch the resulting ingress events:
curl 'http://localhost:8093/api/v1/events?limit=10'

# The enqueued job lands on the workers plugin (:8091):
ns-workers executions --limit=10 --json
```

## How it wires to the rest of the app

This is the last rung of the continuous-app thread. The `generic-inbound-webhook` enqueues the workers health-check job; the `create-user-settings` job (authored in the [background jobs](https://rickylabs.github.io/netscript/background-processing/workers/) tutorial) publishes a `UserSettingsCreated` saga message; the saga from the [durable workflow](https://rickylabs.github.io/netscript/durable-workflows/sagas/) tutorial handles it. One inbound POST drives the whole chain — and every link is real scaffold code that compiles.

## Production notes

> Production pitfalls
>
> - **Never ship the `'memory'` verifier publicly.** It accepts any POST so you can iterate fast. Before exposing a webhook, set `verifier: 'hmac-sha256'` and a `secretEnv` so only a sender holding the secret can enqueue work.
> - **The signing secret lives in env, not the definition.** `secretEnv` names the env var; the runtime resolves it (`resolveSecret`) at ingress. Keep the secret out of source and out of the definition object.
> - **Return fast, work later.** A handler should verify, validate, and `enqueueJob` — not do the work inline. Webhook senders time out and retry; durable processing belongs on the workers plugin.
> - **De-dupe sender retries.** Pass `idempotencyKey` (the event's key, or a stable field) to `enqueueJob` so a retried POST collapses to one job.
> - **File watches need stability on network shares.** Set `stabilityThreshold` so a half-written CSV does not fire mid-copy; use `forcePolling` on the underlying watcher for network filesystems that miss native events.
> - **Do not reach for `defer`.** It is defined-but-unsupported, throws, and routes to the DLQ. Schedule delayed work from the enqueued workers job, or use a `defineScheduledTrigger` instead.

## Where ingress lives, compared

Inngest — the closest neighbor for this event-to-work shape — models ingress as events sent to its platform: your app or a webhook transform sends events to Inngest's event API, and Inngest invokes the matching functions over HTTP, owning the scheduling and retry loop. A NetScript trigger keeps the same shape inside your workspace: the definition is a TypeScript file the runtime walker discovers, ingress is your own `:8093` service verifying HMAC over the raw request bytes, and the retry policy, event store, and dead-letter queue run in your trigger processor under Aspire. The difference to weigh is operational locus, not capability count — with triggers, the pieces that decide whether an event becomes work are resources in your own Aspire graph, inspectable in your own telemetry, and yours to run.

## Reference

[triggers](https://rickylabs.github.io/netscript/netscript/reference/triggers/)

[Look up — triggers core reference  The full generated API surface for the triggers unit: builders (defineWebhook / defineFileWatch / defineScheduledTrigger), runtime (ingress + processor), adapters (HMAC verifier), and the Hono router types.](https://rickylabs.github.io/netscript/netscript/reference/triggers/)

[Look up — watchers reference  createWatcher, FileWatcher, strategies (native/polling/hybrid), and the GlobFilter / StabilityFilter / DedupFilter pipeline.](https://rickylabs.github.io/netscript/netscript/reference/watchers/)

[Do — Background jobs  Where the enqueued work runs: defineJobHandler, worker runtimes, and the enqueue path triggers hand off to.](https://rickylabs.github.io/netscript/netscript/background-processing/workers/)

[Understand — Durable sagas  The next link in the chain: how an enqueued job's saga message advances a durable workflow.](https://rickylabs.github.io/netscript/netscript/durable-workflows/sagas/)

[Durable sagas](https://rickylabs.github.io/netscript/netscript/durable-workflows/sagas/) [Durable streams](https://rickylabs.github.io/netscript/netscript/durable-workflows/streams/)
