Example application
This is the reference instrumentation example: an ATS-style webhook consumer where the entire Seamward integration is the observeWebhook call. Copy it into your own project; everything it needs is the collector package and your two runtime connection values.
The complete example
Code example
import Fastify from "fastify";import { createSeamwardCollector } from "@seamward/collector";
const collector = createSeamwardCollector({ connectionKey: process.env.SEAMWARD_CONNECTION_KEY!, ingestToken: process.env.SEAMWARD_INGEST_TOKEN!, policy: { version: "example-v1", dropFields: [], hashFields: ["candidate_email"], },});
const candidates = new Map<string, Record<string, unknown>>();
const handleCandidateWebhook = collector.observeWebhook( { routeTemplate: "/webhooks/candidates", }, async (payload) => { const candidate = payload as { id?: string }; if (typeof candidate.id !== "string") { return { statusCode: 400, outcome: { accepted: false } }; } candidates.set(candidate.id, candidate); return { statusCode: 202, eventType: "candidate.create", outcome: { accepted: true, businessObjectType: "candidate", businessObjectId: candidate.id, }, }; },);
type WebhookBody = Parameters<typeof handleCandidateWebhook>[0];
const app = Fastify();app.post("/webhooks/candidates", async (request, reply) => { const result = await handleCandidateWebhook(request.body as WebhookBody); return reply.code(result.statusCode ?? 200).send();});
await app.listen({ port: 4190 });
process.once("SIGTERM", async () => { await collector.stop(); process.exit(0);});Three things to notice:
- The business logic is untouched. The handler stores the candidate exactly as it would without Seamward; observation wraps it.
- The outcome is the contract.
accepted,businessObjectType, andbusinessObjectIdare what expected-outcome rules later match on; the malformed-payload branch records a rejection as a decision (400,accepted: false) rather than a crash. - PII is handled at the edge. The policy hashes
candidate_emailfor any pipeline that uses the redaction helpers, and payload values never ship regardless.
Report the signature check and the account
Two things your handler already knows make incidents more useful: whether the provider's signature verified, and which of your accounts the delivery is for. Both are read from the request, so the wrapper is created inside the route where they are in scope:
Code example
app.post("/webhooks/candidates", async (request, reply) => { // Your own code, with or without Seamward. const signatureValid = verifyProviderSignature( request.rawBody, request.headers["x-provider-signature"], ); const account = request.headers["x-provider-account"];
const handle = collector.observeWebhook( { routeTemplate: "/webhooks/candidates" }, async (payload) => { if (!signatureValid) return { statusCode: 401, signatureValid }; const candidate = payload as { id?: string }; if (typeof candidate.id !== "string") { return { statusCode: 400, signatureValid, outcome: { accepted: false }, }; } candidates.set(candidate.id, candidate); return { statusCode: 202, signatureValid, outcome: { accepted: true, businessObjectType: "candidate", businessObjectId: candidate.id, ...(typeof account === "string" ? { accountRef: account } : {}), }, }; }, ); const result = await handle(request.body as WebhookBody); return reply.code(result.statusCode ?? 200).send();});verifyProviderSignature and request.rawBody stand for the check and the raw-body access your framework and provider already give you. What Seamward receives:
| You return | What is sent | What it enables |
|---|---|---|
signatureValid | The boolean. Never the signature or the secret. | A finding when failures rise, which tells a rotated signing secret from a general error spike. See connection health. |
outcome.accountRef | A keyed hash of the value. | An incident that says how many of your accounts it touched, and an export you can match back to them. See recovery. |
A delivery that fails verification is refused before its account header is read into the outcome, so an unverified value is never recorded.
For calls you make to a provider, pass the SDK's response headers from your observeCall extractor and the account as a call option. Seamward reads the provider's deprecation, sunset, API version and rate-limit headers locally and sends only those dates, labels and numbers:
Code example
const listCandidateEvents = { method: "GET", routeTemplate: "/api/candidates/:candidateId/events", extract: (result) => ({ statusCode: result.lastResponse.statusCode, payload: result.data, headers: result.lastResponse.headers, }),};
await collector.observeCall( listCandidateEvents, () => sdk.candidates.listEvents(candidateId), { reference: candidateId, accountRef: account },);Those headers are what advisories and the rate-limit meter are built from.
Run it against your workspace
- Select the workspace's Development environment and connect an Integration there instead of using Production.
- Set
SEAMWARD_CONNECTION_KEYandSEAMWARD_INGEST_TOKENin the example's environment. - Install the collector per the Node guide, start the app, and POST a candidate to
/webhooks/candidates. - Confirm the first observation from the integration page; the evidence you see contains fingerprints and hashes, and not one payload value.
Produce a known incident
With the example running, controlled changes produce each failure class on demand:
- Send the same payload with
idrenamed tocandidate_id: a structural finding. - Send
idas a number instead of a string: atype-changedfinding. - Create an expected-outcome rule for
candidate.create, then send an event whose downstream result never arrives: a missing-outcome incident after the rule's delay window.
Keep these experiments in the test integration; baselines are per-integration, so production evidence stays clean.
Replay against its handler
When an incident on this integration has a replay dataset, the replay runner can run those fixtures through the example's own logic. Move the decision into a function both the route and the runner can call, then declare the handler:
Code example
// src/candidate.tsexport function candidateFrom(payload: unknown) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; const fields = payload as Record<string, unknown>; // The provider renamed candidate_email to email_address. Read either. const email = fields.candidate_email ?? fields.email_address; if (typeof fields.id !== "string" || fields.id === "") return null; if (typeof email !== "string" || !email.includes("@")) return null; return { id: fields.id, email };}Code example
// replay-handler.mjsimport { candidateFrom } from "./src/candidate.ts";
export default function handle(payload) { const candidate = candidateFrom(payload); return { accepted: candidate !== null, businessObjectType: "candidate" };}On the fixtures of a renamed-field incident, the version of candidateFrom that reads only candidate_email fails every fixture, and the version above passes. Because this handler checks that the email contains @, it needs a profile dataset to pass: a shape dataset holds a placeholder string in that field. Replay explains the difference.
Observe another protocol
The same SDK wraps every protocol without changing your existing clients. Create a separate Integration and Connection-key-bound collector for each direction/protocol boundary:
| Operation | Collector entry point | Metadata you provide |
|---|---|---|
| Outbound HTTP API | observeFetch | stable route template, optional event type, request or response side |
| Provider SDK call | observeCall | one reusable operation definition, optional extractors |
| Inbound webhook | observeWebhook | stable route template, handler outcome |
| Queue publish | observeQueuePublish | stable queue or topic label |
| Queue consume | observeQueueConsumer | stable queue, topic, or subscription label |
| Scheduled import or export | observeScheduledFeed | stable feed label and direction |
The queue and scheduled-feed wrappers are broker-neutral; broker-specific adapters are not shipped. Follow the Node.js collector guide, PHP collector guide, or Laravel collector guide for runtime-specific examples.
Next steps
- Node collector guide: the full setup this example assumes.
- Replay runner: run an incident's fixtures against the handler above.
- Collector SDK reference: every wrapper, result, and compatibility status.
- Detect silent business failures: what the outcome contract enables.
