Observe webhooks

observeWebhook records what an inbound webhook actually did: whether the handler accepted it, which business object it produced, and what the payload looked like structurally. Getting three things right here (the boundary, the outcome, and the route template) determines how useful every later drift finding and incident is.

Choose the boundary

Wrap the handler that converts a provider event into an application result: one stable external workflow per Integration. Do not combine unrelated providers or business outcomes in one Integration, and do not wrap framework plumbing that runs before your handler.

Code example

const handleCandidateWebhook = seamward.observeWebhook(  {    routeTemplate: "/webhooks/candidates",  },  existingCandidateHandler,);

Preserve behavior

The wrapper invokes your existing handler and records its result after the operation. Nothing about your webhook contract changes:

  • Your handler's return value is passed through unchanged, and a thrown error is recorded (statusCode: 500, accepted: false) and rethrown unchanged.
  • Provider acknowledgement, retry, and idempotency logic stay yours. The collector never replaces a response.
  • If Seamward is unreachable, evidence queues and eventually drops; your handler is unaffected.

Report the outcome

The value your handler returns is the contract that expected-outcome rules later key on:

Code example

async (payload) => {  const candidate = await candidateService.accept(payload);  return {    statusCode: 202,    eventType: "candidate.create",    outcome: {      accepted: true,      businessObjectType: "candidate",      businessObjectId: candidate.id,    },    correlation: { sourceEventId: candidate.providerEventId },  };};
  • outcome.accepted states whether the business operation succeeded, independent of transport status.
  • businessObjectType plus businessObjectId identify what was produced; the id is keyed-hashed in your process before transmission, and reconciliation matches on the hash.
  • correlation.sourceEventId ties this event to the downstream result that should follow it; it is hashed the same way.
  • On a malformed payload, return { statusCode: 400, outcome: { accepted: false } } rather than throwing, so the rejection is recorded as a decision, not a crash.

Report a signature result

Your handler already verifies the provider's signature. Return the result and Seamward can tell a signing secret that was rotated on one side from a general error spike:

Code example

async (payload) => {  const signatureValid = verifyProviderSignature(rawBody, signatureHeader);  if (!signatureValid) return { statusCode: 401, signatureValid };  const candidate = await candidateService.accept(payload);  return {    statusCode: 202,    signatureValid,    outcome: { accepted: true, businessObjectType: "candidate" },  };};

signatureValid is a boolean and nothing else is sent: not the signature, the header, or the secret. In Laravel, set the ObserveWebhook::SIGNATURE_VALID request attribute; see the Laravel reference. Connection health describes when a rise in failures raises a finding.

Report the provider's sequence

When a provider numbers its deliveries, pass that number on. Seamward then reports positions that never arrived, which is the only way to notice individual lost webhooks on a connection that is otherwise busy:

Code example

async (payload) => {  const event = payload as { sequence: number };  await eventService.accept(payload);  return {    statusCode: 202,    correlation: { sequence: event.sequence },  };};

The value must be a whole number from the provider that increases by one per delivery. Delivery order does not matter: a late webhook is not a lost one, and Seamward waits 15 minutes before judging an hour. Do not pass timestamps or opaque cursors; a range far wider than the positions seen is ignored rather than reported as a gap. An unusable value is left out and the observation is still recorded. See missing traffic.

Route templates

routeTemplate and eventType become the operation's identity across every observation, so they must be stable and low-cardinality:

  • Use /webhooks/candidates, never /webhooks/candidates/123 or anything containing an id, email, or query string.
  • Use eventType for the provider's event name (candidate.create), never for identifiers.
  • Changing a template later splits the operation's baseline; pick templates you can keep.

When a route has parameters, write them by name: /webhooks/:provider/events or /webhooks/{provider}/events. Both forms identify the same operation. The names are also kept as a readable label, so the console shows /webhooks/:provider/events instead of an anonymous placeholder. Renaming a parameter changes only the label, never the operation's identity.

Next steps