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 · Scaffold
- 2 · Catalog service
- 3 · Cart contracts
- 4 · Checkout saga
- 5 · Shipping webhook
- 6 · Storefront UI
- 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 theproductsservice, thecartcontract, and thesagasplugin with yourCheckoutSagaand theprocess-paymentworker job.aspire startis 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 aPromiseof the effect array (never a barefunction-keyword declaration).verifier: 'hmac-sha256'+secretEnvis the security seam. The triggers ingress verifies the request's HMAC signature against the secret named bysecretEnv(hereWEBHOOK_SHIPPING_SECRET) before your handler is invoked. A request that fails verification never reaches your code. For local experiments you can useverifier: 'memory'(the open, no-signature verifier), but a real provider callback should behmac-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 theprocess-shipping-updatejob with the verified inbound payload.
| 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. |
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:
| 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.
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 --sampleslandedplugins/triggers/. - [ ]
shipping-status-webhook.tsusesverifier: 'hmac-sha256'with asecretEnv. - [ ]
WEBHOOK_SHIPPING_SECRETis set in the environment running the triggers service. - [ ]
curl <triggers-endpoint>/healthanswers. - [ ] A verified
POSTrecords an event on the triggers API and produces a worker execution.
What you built
- The
triggersplugin underplugins/triggers/. - An HMAC-SHA256 verified webhook authored with
defineWebhook(handler, { id, path, verifier, secretEnv, ... })that rejects forged requests andenqueueJob(...)s aprocess-shipping-updatejob on each accepted one. - A verified inbound
POSTconfirmed 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.