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.acceptedstates whether the business operation succeeded, independent of transport status.businessObjectTypeplusbusinessObjectIdidentify what was produced; the id is keyed-hashed in your process before transmission, and reconciliation matches on the hash.correlation.sourceEventIdties 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/123or anything containing an id, email, or query string. - Use
eventTypefor 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
- Detect silent business failures: what the outcome contract enables.
- Redaction: how identifiers are hashed before transmission.
