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 · 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 on
:8093 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 http://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 sagas (and its streams dependency) 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:
deno run -A packages/cli/bin/netscript-dev.ts plugin add trigger --name triggers --samples
This local-source contributor 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 verified webhook
A webhook is defineWebhook(handler, spec) from @netscript/plugin-triggers-core/builders. The
handler resolves to an array of effects; the spec names the webhook and — for a real provider
callback — declares HMAC verification so forged requests are rejected before your handler runs.
First, a small helper that produces a typed reference to the worker job you want to enqueue. This
mirrors the playground's localJob helper:
// plugins/triggers/_jobs.ts
import type { JobDefinition, JobId } from '@netscript/plugin-workers-core';
export function localJob<TId extends string>(id: TId): JobDefinition<TId> {
return Object.freeze({
id: id as JobId<TId>,
entrypoint: `./workers/jobs/${id}.ts`,
name: id.split('-').filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join(' '),
topic: 'default',
});
}
Now the webhook itself. It verifies the inbound signature with verifier: 'hmac-sha256' against a
secret read from the environment, and enqueues a process-shipping-update job with the request
payload:
// plugins/triggers/shipping-status-webhook.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import { localJob } from './_jobs.ts';
const processShippingJob = localJob('process-shipping-update');
export default defineWebhook(
// The handler returns effects. Here: enqueue one job with the inbound payload.
async (event) => [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 is anasync/arrow function — never a barefunction— and resolves to an array of effects.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 — Know which trigger actions are supported
A handler returns an array of trigger actions (effects). Be precise about which ones the runtime actually dispatches today, so you do not author against a stub:
| 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 }) |
Defined, not yet supported |
The action type exists in the builder surface, but the runtime processor throws on dispatch and routes the event to the dead-letter queue. There is no deferred replay yet — do not rely on it. |
In other words: build with enqueueJob(...). If you return a defer(...) action, the trigger runtime
raises an unsupported-operation error and the event lands in the DLQ rather than being scheduled —
reach for the scheduling features of the triggers capability (cron and
file-watch triggers) when you need time-based work, not defer.
Step 4 — Route shape (Hono, not oRPC)
The triggers API service is built on raw Hono, not oRPC — deliberately.
Webhooks come from third parties posting plain JSON to a fixed path, so a typed contract would buy
nothing. The webhook router is a new Hono() with a POST /:triggerId handler: 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 port :8093 by default. Confirm it is alive:
curl http://localhost:8093/health
Verify your progress
Send an inbound request to your webhook's path. Because the webhook is hmac-sha256-verified, a real
sender includes a signature header computed from the body and the shared secret; the carrier's
dashboard does this for you. For local testing, point the verifier at 'memory' temporarily, or
compute the HMAC and pass it in the signature header your provider uses. With verification satisfied:
curl -X POST http://localhost:8093/api/v1/webhooks/shipping/status \
-H "content-type: application/json" \
-d '{"orderId":"ord_1001","status":"shipped","trackingNumber":"1Z999"}'
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, :8093)
curl "http://localhost:8093/api/v1/events?limit=10"
# 2. The worker job it enqueued has executed (workers API, :8091)
curl "http://localhost:8091/api/v1/workers/executions?limit=10"
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.
- [ ]
deno run -A packages/cli/bin/netscript-dev.ts plugin add 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 http://localhost:8093/healthanswers. - [ ] A verified
POSTrecords an event (:8093) and produces a worker execution (:8091).
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 (:8093) and a worker execution on the workers API (:8091).
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.