Skip to main content
0.0.x

A verified shipping webhook

In chapter 4 your checkout saga sends 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 POSTs to, which hands each inbound event straight to a background job. Triggers are how NetScript receives events from the world.

  1. 1 · Scaffold
  2. 2 · Catalog service
  3. 3 · Cart contracts
  4. 4 · Checkout saga
  5. 5 · Shipping webhook
  6. 6 · Storefront UI
  7. 7 · 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, 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). 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:

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:

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:

netscript plugin list

You should now see triggers alongside sagas.

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 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:

// 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:

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:

// 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
NameTypeDescription
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.

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
NameTypeDescription
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.

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):

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). To run the API on its own during development, start it from the plugin workspace:

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 resource list — triggers-api — and confirm it is alive:

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:

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:

# 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.

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.