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, and businessObjectId are 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_email for 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 returnWhat is sentWhat it enables
signatureValidThe 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.accountRefA 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

  1. Select the workspace's Development environment and connect an Integration there instead of using Production.
  2. Set SEAMWARD_CONNECTION_KEY and SEAMWARD_INGEST_TOKEN in the example's environment.
  3. Install the collector per the Node guide, start the app, and POST a candidate to /webhooks/candidates.
  4. 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 id renamed to candidate_id: a structural finding.
  • Send id as a number instead of a string: a type-changed finding.
  • 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:

OperationCollector entry pointMetadata you provide
Outbound HTTP APIobserveFetchstable route template, optional event type, request or response side
Provider SDK callobserveCallone reusable operation definition, optional extractors
Inbound webhookobserveWebhookstable route template, handler outcome
Queue publishobserveQueuePublishstable queue or topic label
Queue consumeobserveQueueConsumerstable queue, topic, or subscription label
Scheduled import or exportobserveScheduledFeedstable 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