# A verified shipping webhook

In [chapter 4](https://rickylabs.github.io/netscript/tutorials/storefront/04-checkout-saga/) your checkout saga `send`s a `create-shipment` command and waits for a `ShipmentCreated` message. But the real signal that a parcel shipped comes from *outside* your shop — from a carrier or payment provider posting a webhook. This chapter adds that ingress: an HMAC-verified webhook endpoint that the provider `POST`s to, which hands each inbound event straight to a background job. Triggers are how NetScript receives events from the world.

1. [1 · Scaffold](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/01-scaffold/)
2. [2 · Catalog service](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/02-catalog-service/)
3. [3 · Cart contracts](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/03-cart-contracts/)
4. [4 · Checkout saga](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/04-checkout-saga/)
5. [5 · Shipping webhook](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/05-shipping-webhook/)
6. [6 · Storefront UI](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/06-storefront-ui/)
7. [7 · Deploy](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/07-deploy/)

## What you will build

You will add the `triggers` plugin, then author a webhook with `defineWebhook(...)` that is **HMAC-SHA256 verified** against a shared secret and, on each accepted request, `enqueueJob(...)`s a worker job to process the shipping update off the request path. You will start the triggers API and `POST` a real payload to it, watching the inbound event get recorded and the job enqueued.

## Before you begin

You should have finished [chapter 4](https://rickylabs.github.io/netscript/tutorials/storefront/04-checkout-saga/), so:

- `my-shop/` has the `products` service, the `cart` contract, and the `sagas` plugin with your `CheckoutSaga` and the `process-payment` worker job.
- `aspire start` is up (the dashboard answers at [https://localhost:18888](https://localhost:18888)). The triggers processor and its job hand-off depend on Deno KV and the workers runtime, so Aspire must be up before you start.

Confirm the plugins you have so far:

```sh
netscript plugin list
```

You should see `workers`, `sagas`, and `streams` from the previous chapter. You will add `triggers` next.

## Step 1 — Add the triggers plugin

From the project root, add the official triggers plugin with its sample modules:

```sh
netscript plugin install trigger --name triggers --samples
```

This command lands a new workspace at `plugins/triggers/` and registers it in `netscript.config.ts` (`./plugins/triggers/mod.ts`) and `appsettings.json`. The `--samples` flag also drops in working webhook modules and a small `jobs/` folder you can study. Confirm it registered:

```sh
netscript plugin list
```

You should now see `triggers` alongside `sagas`.

> Where the plugin really lives
>
> The canonical install is
>
> plugins/triggers/
>
> — the path
>
> netscript.config.ts
>
> points at. You may also see a slimmer top-level
>
> triggers/
>
> staging copy; author and read your webhooks under
>
> plugins/triggers/
>
> .

## Step 2 — Author the shipping-update job and the verified webhook

The webhook hands each inbound event to a background job, so scaffold that job first:

> ns-workers / ns-triggers are shorthands you install once
>
> ns-workers
>
> and
>
> ns-triggers
>
> are names
>
> you
>
> give the workers and triggers plugin CLIs — the scaffold does not create them. Install them once, globally, and every shorthand command on this page works as written:
>
> ```bash
> deno install -gArf -n ns-workers jsr:@netscript/plugin-workers@0.0.6/cli
> deno install -gArf -n ns-triggers jsr:@netscript/plugin-triggers@0.0.6/cli
> ```
>
> Rather not install them? Each
>
> ns-<plugin> <verb …>
>
> is exactly
>
> deno x -A jsr:@netscript/plugin-<plugin>@0.0.6/cli <verb …>
>
> — run that full form instead.

```sh
ns-workers add job process-shipping-update
```

Extend the generated payload schema with `orderId`, `status`, and `trackingNumber`, then replace the starter handler body with the validation and application logic. A real integration would advance the order; here it validates and records the update:

```ts
// workers/jobs/process-shipping-update.ts
import { createSuccessResult, defineJobHandler } from '@netscript/plugin-workers-core';
import { z } from 'zod';

const PayloadSchema = z.object({
  orderId: z.string().min(1),
  status: z.string().min(1),
  trackingNumber: z.string().optional(),
});

const handler = defineJobHandler(async (ctx) => {
  const update = PayloadSchema.parse(ctx.payload ?? {});
  // ... advance the order / notify the customer ...
  return createSuccessResult(update);
});

export default Object.assign(handler, { id: 'process-shipping-update' });
```

Scaffold the webhook and its worker-job action with the triggers CLI first:

The `add-webhook` verb uses the spaced `add webhook` shell syntax:

```bash
ns-triggers add webhook shipping-status-webhook \
  --path=shipping/status \
  --job=process-shipping-update \
  --verifier=hmac-sha256 \
  --secret-env=WEBHOOK_SHIPPING_SECRET \
  --description="Receives carrier shipping-status callbacks and enqueues a processing job." \
  --tags=webhook,shipping,saga
```

The command writes `triggers/shipping-status-webhook-trigger.ts` and recompiles the generated trigger registry. The generated definition uses `defineWebhook(handler, spec)` from `@netscript/plugin-triggers-core/builders`: the handler resolves to an **array of effects**, and the spec names the webhook and — for a real provider callback — declares HMAC verification so forged requests are rejected before your handler runs. The job reference is an inline object typed with `satisfies JobDefinition` — no helper needed. Its relevant shape is:

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

// A typed reference to the worker job authored above.
const processShippingJob = {
  id: 'process-shipping-update' as JobDefinition<'process-shipping-update'>['id'],
  name: 'Process Shipping Update',
  topic: 'default',
} satisfies JobDefinition<'process-shipping-update'>;

export default defineWebhook(
  // The handler returns effects. Here: enqueue one job with the inbound payload.
  (event) => Promise.resolve([enqueueJob(processShippingJob, { payload: event.payload })]),
  {
    id: 'shipping-status-webhook',
    path: 'shipping/status',
    verifier: 'hmac-sha256',
    secretEnv: 'WEBHOOK_SHIPPING_SECRET',
    description: 'Receives carrier shipping-status callbacks and enqueues a processing job.',
    tags: ['webhook', 'shipping', 'saga'],
    metadata: {
      direction: 'inbound',
      pipeline: 'shipment-fulfillment',
      provider: 'carrier',
    },
  },
);
```

Three things to read off this:

- **`defineWebhook(handler, spec)`** takes the handler first, then the static spec. The handler resolves to an **array of effects** — return a `Promise` of the effect array (never a bare `function`-keyword declaration).
- **`verifier: 'hmac-sha256'` + `secretEnv`** is the security seam. The triggers ingress verifies the request's HMAC signature against the secret named by `secretEnv` (here `WEBHOOK_SHIPPING_SECRET`) *before* your handler is invoked. A request that fails verification never reaches your code. For local experiments you can use `verifier: 'memory'` (the open, no-signature verifier), but a real provider callback should be `hmac-sha256`.
- **`enqueueJob(jobRef, { payload })`** is the effect that bridges to the worker system. Each effect the handler returns is applied after the request is accepted; this one enqueues the `process-shipping-update` job with the verified inbound payload.

**defineWebhook spec fields**

| Name | Type | Description |
| --- | --- | --- |
| `id` | `string` | Stable identifier for the webhook in the registry. |
| `path` | `string` | URL segment the router mounts it under — here shipping/status, reached at /api/v1/webhooks/shipping/status. |
| `verifier` | `'hmac-sha256' \| 'memory' \| string` | Signature verifier. hmac-sha256 checks the request HMAC against secretEnv; memory is the open local-dev verifier. |
| `secretEnv` | `string (optional)` | Name of the env var holding the shared secret used by the hmac-sha256 verifier. |
| `tags / metadata` | `optional` | Discovery metadata — not behavior. Useful for grouping and dashboards. |

> An effect array, not a response body
>
> A webhook handler does not write an HTTP response itself — it returns the
>
> effects
>
> to run. Returning
>
> []
>
> accepts the request but enqueues nothing; returning one or more
>
> enqueueJob(...)
>
> entries hands that many jobs to the workers runtime. This keeps inbound HTTP thin and pushes the real work onto the durable background queue.

## Step 3 — Choose immediate work or one-shot replay

A handler returns an array of **trigger actions** (effects). Be precise about which ones the runtime dispatches:

**Trigger actions**

| Name | Type | Description |
| --- | --- | --- |
| `enqueueJob(jobRef, opts)` | `Live` | Places a worker job on the queue. The supported way to turn an inbound event into durable background work. |
| `defer({ until })` | `Live` | Persists the event and replays its handler once at or after the ISO timestamp, surviving runtime restarts. |

Use `enqueueJob(...)` when work is ready now. Use `defer({ until })` when the same event should be processed again once at a future instant. Deferred delivery is at-least-once, so keep the handler idempotent; use cron scheduling for recurring work.

## Step 4 — Route shape (raw webhook ingress)

The triggers service serves a typed v1 oRPC contract for trigger and event introspection plus management, but the **webhook ingress endpoint** is deliberately a **raw route**, not an oRPC procedure. Webhooks come from third parties posting plain JSON to a fixed path, and the endpoint must verify an HMAC signature over the raw request bytes, so a typed contract would buy nothing. The ingress handler resolves a `POST /:triggerId` path: a request to `/api/v1/webhooks/shipping/status` resolves `:triggerId` to `shipping/status`, which matches the `path` on your webhook — so the handler runs and its effects are applied.

> Why the webhook ingress endpoint stays a raw route
>
> oRPC gives the rest of the triggers surface a typed contract shared with its clients. Webhooks have no NetScript client — the sender is a third party — so the ingress endpoint stays an ordinary, signature-verifying route you can point any webhook source at. See
>
> the triggers capability
>
> for verifiers, scheduling, and file-watch triggers.

## Step 5 — Set the secret and start the triggers service

The `hmac-sha256` verifier needs its secret in the environment. Set it before starting the service (use the same value when you sign your test request):

```sh
export WEBHOOK_SHIPPING_SECRET=dev-shipping-secret
```

If `aspire start` is up it orchestrates the triggers API and its background processor for you (look for the `triggers-api` and `triggers` resources in the [dashboard](https://localhost:18888)). To run the API on its own during development, start it from the plugin workspace:

```sh
deno task --cwd plugins/triggers dev
```

The triggers API listens on a host port the installer picked, so read its endpoint from the [Aspire dashboard](https://rickylabs.github.io/netscript/explanation/aspire/) resource list — `triggers-api` — and confirm it is alive:

```sh
curl <triggers-endpoint>/health
```

Everything below writes `<triggers-endpoint>` for that value.

## Verify your progress

Send an inbound request to your webhook's path. Because the webhook is `hmac-sha256`-verified, a real sender includes an **`x-hub-signature-256`** header — a hex HMAC-SHA256 of the raw body under the shared secret (optionally `sha256=`-prefixed); the carrier's dashboard computes it for you. For a quick local smoke, flip `verifier` to `'memory'` (the open, no-signature verifier) so a bare `POST` is accepted; switch back to `'hmac-sha256'` for anything real. With verification satisfied:

```sh
curl -X POST <triggers-endpoint>/api/v1/webhooks/shipping/status \
  -H "content-type: application/json" \
  -d '{"orderId":"ord_1001","status":"shipped","trackingNumber":"1Z999"}'
```

An accepted request returns **`202`**.

The request resolves the trigger id `shipping/status`, your handler runs, and its single `enqueueJob(...)` effect places the `process-shipping-update` job on the workers queue. Confirm both sides of the hand-off:

```sh
# 1. The trigger recorded the inbound event (Hono ingress)
curl "<triggers-endpoint>/api/v1/events?limit=10"

# 2. The worker job it enqueued has executed (workers API; the CLI resolves it)
ns-workers executions --limit=10 --json
```

You should see the inbound event listed by the triggers events endpoint and a fresh execution of `process-shipping-update` in the workers executions list. One verified webhook hit, handed off to a durable job.

- [ ] `netscript plugin install trigger --name triggers --samples` landed `plugins/triggers/`.
- [ ] `shipping-status-webhook.ts` uses `verifier: 'hmac-sha256'` with a `secretEnv`.
- [ ] `WEBHOOK_SHIPPING_SECRET` is set in the environment running the triggers service.
- [ ] `curl <triggers-endpoint>/health` answers.
- [ ] A verified `POST` records an event on the triggers API and produces a worker execution.

> If the job does not appear
>
> - Make sure `aspire start` is up — the workers runtime and KV must be live for the enqueued job to execute.
> - Check that `WEBHOOK_SHIPPING_SECRET` is set and that your request's signature matches; a failed HMAC verification rejects the request before the handler runs.
> - Confirm the job id in `enqueueJob(...)` matches a registered worker job. An unknown id is accepted at the webhook but has nothing to run.
> - Check the `triggers` processor resource in the Aspire [dashboard](https://localhost:18888) for errors — the background processor, not the API, drains the effects.

## What you built

- The `triggers` plugin under `plugins/triggers/`.
- An **HMAC-SHA256 verified** webhook authored with `defineWebhook(handler, { id, path, verifier, secretEnv, ... })` that rejects forged requests and `enqueueJob(...)`s a `process-shipping-update` job on each accepted one.
- A verified inbound `POST` confirmed end to end: an event recorded on the triggers API and a worker execution on the workers API.

Your storefront now spans the full arc — catalog, cart, durable checkout, and a verified webhook from the outside world. The last chapter runs the whole thing as one orchestrated system on your machine.

[4 · Checkout saga](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/04-checkout-saga/) [6 · Storefront UI](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/06-storefront-ui/)
