Observation envelope

Every observation is one JSON envelope. The schema is strict: unknown keys are rejected, and there is no field anywhere in it for raw payload bodies or request headers, which is what makes the privacy posture provable rather than aspirational. Seamward collector SDKs build envelopes for you; this page is the contract for reading evidence or building envelopes yourself.

Envelope fields

The current envelope version is 0.9. Seamward continues to accept unchanged 0.1 through 0.8 envelopes. Version 0.3 added an explicit indication that payload inspection was skipped. Version 0.4 added a readable display route template, a transport status state, a correlation sequence, and an expected interval for scheduled feeds. Version 0.5 adds an optional value profile beside the payload shape. Every addition is optional, and each one is rejected on a version older than the one that introduced it.

The current Node.js, PHP, and Laravel collectors emit 0.4, or a later version when an observation carries a field that version introduced; see Version compatibility.

FieldTypeRequiredConstraints
envelopeVersionstringyes"0.5" when a value profile is sent, otherwise "0.4"; "0.1" to "0.3" remain readable
eventIdstringyesStarts with evt_; the idempotency key for ingestion
tenantIdstringyesStarts with ten_; send ten_authenticated, replaced from your credential
environmentIdstringyesStarts with env_; send env_authenticated, replaced from your credential
integrationIdstringyesYour public Integration key sw_int_... (resolved internally by Seamward)
directionenumyesinbound or outbound
protocolenumyeshttp-webhook, http-api, scheduled-feed, or queue
occurredAtstringyesISO 8601 datetime
operationIdentityVersionstringyes in v0.2+Literal "1"
eventTypestringno1 to 128 chars matching [A-Za-z0-9._:-]; a low-cardinality event label such as candidate.completed, never an identifier
deploymentobjectnoRelease context; see below
correlationobjectyesMay be empty {}; see below
contractobjectyesSee below
transportobjectyesSee below
payloadobjectyesStructural evidence; see below
outcomeobjectyesSee below

transport

FieldTypeRequiredConstraints
methodenumyesGET, POST, PUT, PATCH, DELETE
routeTemplatestringyesNon-empty. A stable template like /webhooks/candidates, never a concrete id or query string
displayRouteTemplatestringnoFrom v0.4, HTTP protocols only. The same route with named parameters, such as /v1/payment_intents/:paymentIntentId
payloadLocationenumyes in v0.2+request, response, or message
statusCodeintegeryesExactly 0, or 100 through 599. 0 means no HTTP status is known; statusState says why
statusStateenumnoFrom v0.4. known, no-response, or unknown; see Status state
durationMsnumberyesZero or greater
attemptintegeryes1 to 2,147,483,647
expectedIntervalMsintegernoFrom v0.4, scheduled-feed only. How often a run is expected, from 1 to 31,622,400,000 (366 days)
deprecationobjectnoOnly in v0.7. What the provider's response headers announced; see Deprecation
rateLimitobjectnoOnly in v0.8. How much of the provider's allowance is left; see Rate limit

Deprecation

transport.deprecation records what a provider said about an operation's future in its response headers. It holds dates and one version label, never a header's text.

FieldTypeConstraints
deprecatedbooleanOnly ever true. The response carried a Deprecation header
deprecatedAtstringISO 8601 UTC date-time. When the deprecation took or takes effect
sunsetAtstringISO 8601 UTC date-time, from the Sunset header. When the operation stops working
apiVersionstring1 to 64 characters of letters, digits, ., _ or -. The API version the call was served under

At least one field is present; an empty object is rejected. Any other key is rejected.

The headers read are Deprecation, Sunset, and the first present of API-Version, X-API-Version, Stripe-Version, X-GitHub-Api-Version, X-Shopify-API-Version, Anthropic-Version, OpenAI-Version, Notion-Version and X-MS-Version. A value that is not a date between 2000 and 2100 or a version label is left out. deprecationFromHeaders in @seamward/contracts applies exactly these rules if you build envelopes yourself:

Code example

import { deprecationFromHeaders } from "@seamward/contracts";
const deprecation = deprecationFromHeaders(response.headers);// { deprecated: true, sunsetAt: "2027-03-01T00:00:00.000Z", apiVersion: "2026-10" }// or undefined when the headers announce nothing

Rate limit

transport.rateLimit records what a provider's response headers said about its allowance. It holds whole numbers only.

FieldTypeConstraints
limitinteger1 to 1,000,000,000,000. Requests allowed in the provider's current window
remaininginteger0 to 1,000,000,000,000. Requests left in that window
resetSecondsinteger0 to 31,622,400. Seconds until the allowance resets, or until a retry is welcome

At least one field is present; an empty object and any other key are rejected.

The headers read, in priority order, are RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; the same names prefixed X-RateLimit- and X-Rate-Limit-; the combined RateLimit: limit=…, remaining=…, reset=… form; and Retry-After in seconds when no reset is given. A limit that lists its windows (100, 100;w=60) is read as its first number. A reset given as a clock time in seconds since 1970 is turned into a wait using the collector's clock. A value that is not a whole number in range is left out. rateLimitFromHeaders in @seamward/contracts applies exactly these rules:

Code example

import { rateLimitFromHeaders } from "@seamward/contracts";
const rateLimit = rateLimitFromHeaders(response.headers, Date.now());// { limit: 5000, remaining: 4200, resetSeconds: 1800 }// or undefined when the headers carry no meter

Display route template

routeTemplate is the identity of an operation, so parameters are anonymous there: :id and {id} both become {}. displayRouteTemplate keeps the names so an operation reads as /v1/payment_intents/:paymentIntentId in the console and the API. It is display metadata only and never changes how observations are grouped.

Seamward accepts a display template only when all of these hold:

  • The protocol is http-api or http-webhook. Queue and feed labels are already readable.
  • It is an absolute path of at most 256 characters with no query string, fragment, whitespace, or control character.
  • Every parameter is written as :name, where the name starts with a letter or underscore and continues with letters, digits, or underscores, up to 64 characters. Brace syntax and anonymous {} parameters are rejected.
  • Replacing each :name with {} gives exactly the envelope's normalized routeTemplate.

The last rule is what keeps identifiers out. A display template of /v1/payment_intents/pi_3Nabc does not reduce to /v1/payment_intents/{}, so the envelope is rejected with display route template must match the canonical route template.

Code example

{  "method": "GET",  "routeTemplate": "/v1/payment_intents/{}",  "displayRouteTemplate": "/v1/payment_intents/:paymentIntentId",  "payloadLocation": "response",  "statusCode": 200,  "durationMs": 84,  "attempt": 1}

Status state

Status 0 covers two different facts. statusState separates them:

statusStatestatusCodeMeaningoutcome.accepted
known100 to 599A real HTTP status was readeither
no-response0No HTTP response was received, for example a refused connectionmust be false
unknown0A response may exist, but the collector could not read its statuseither

When statusState is omitted, Seamward derives it: 0 is no-response and any other status is known. A known state with status 0, or any other state with a non-zero status, is rejected.

An unknown observation is evidence that a call happened, not evidence that it failed. Seamward keeps it out of error-rate calculations in both directions, so it neither raises nor dilutes the rate, and it still counts toward latency. A client library that never exposes an HTTP status therefore stays visible without producing false error findings.

Protocol identity and compatibility

An operation identity combines direction, protocol, method, normalized routeTemplate, eventType, and payloadLocation. A status selector can participate in registered-contract matching, but the observed status does not split the operation-family key. Keep every identity field stable and low-cardinality.

HTTP observations use HTTP-native values. Queue and scheduled-feed collectors expose protocol-native metadata, then project it into the same transport object for envelope 0.2 compatibility:

Observed operationdirectionprotocolmethodrouteTemplatepayloadLocation
Outbound API calloutboundhttp-apiHTTP verbstable absolute pathresponse or request
Inbound API callinboundhttp-apiHTTP verbstable absolute pathrequest
Inbound webhookinboundhttp-webhookHTTP verbstable absolute pathmessage
Queue publishoutboundqueuePOSTqueue or topic labelmessage
Queue consumeinboundqueuePOSTqueue, topic, or subscription labelmessage
Scheduled importinboundscheduled-feedPOSTfeed or job labelmessage
Scheduled exportoutboundscheduled-feedPOSTfeed or job labelmessage

For HTTP protocols, the route must be an absolute path without a query string. :id and {id} segments normalize to {}. For queues and feeds, the target is a label of at most 256 characters matching letters, numbers, ., _, :, /, or -, and it must start with a letter or number. Never use a concrete id, message id, run id, timestamp, or customer value.

The Node.js, PHP, and Laravel collectors use these compatibility status values for non-HTTP wrappers: queue acknowledgement 202, feed completion 200, explicit rejection 422, and thrown host error 500. These numbers describe the envelope projection. They do not command or replace broker acknowledgement, retry, dead-letter, or scheduler behavior.

payload (structural evidence, never values)

FieldTypeRequiredConstraints
storageenumyesnone (the default posture: the body was dropped in your process) or encrypted-object
schemaFingerprintstringyessha256: plus 64 hex chars
schemaShapeshapeyesThe recursive structure description; grammar below
schemaInspectedbooleannoOnly in v0.3+. false marks transport-only evidence whose placeholder shape must not be used for structural drift. Absence means the legacy inspection interpretation applies.
valueProfileobjectnoOnly in v0.5. Descriptions of field values, never the values; see Value profile. Rejected when schemaInspected is false
redactionPolicyVersionstringyesThe policy version active when the observation was built
objectRefstringonly with encrypted-objectPresent if and only if storage is encrypted-object

contract

FieldTypeRequiredConstraints
declaredVersionstringnoThe contract version you registered, when one exists
observedFingerprintstringyessha256: plus 64 hex chars

outcome

FieldTypeRequiredConstraints
acceptedbooleanyesWhether the operation reached its intended result. Cannot be true when no response was received (see Status state)
businessObjectTypestringnoDomain type such as candidate or payment
businessObjectIdHashstringnoKeyed hash of the business object id; never the raw id
amountobjectnoOnly in v0.6. value: a finite number between -1e15 and 1e15. unit: 1 to 32 characters of letters, digits, ., _ or -, such as GBP or minor_units. The amount your application recorded for this outcome
statestringnoOnly in v0.6. 1 to 64 characters of letters, digits, ., _ or -, such as paid. The state your application's record is in
signatureValidbooleannoOnly in v0.8. Whether your application's own check of a webhook's signature passed. A result only: no signature or secret is carried
accountRefHashstringnoOnly in v0.9. Keyed hash of your own customer or account identifier, in canonical sha256: form. Lets an incident count the accounts it affects. Never the identifier or a name

amount and state are facts your application chooses to report about its own record, in the same way as businessObjectType. The collector never reads them from a payload. Report totals and state names only; do not put an identifier, a name, or any other personal value in either field.

correlation (all fields optional, hashes only)

FieldConstraints
traceIdNon-empty string; safe to send as-is because trace ids are operational, not personal
sourceEventIdHashKeyed hash of the provider's event id
idempotencyKeyHashKeyed hash of the idempotency key
hashNamespaceNon-secret key-generation identifier; required in v0.2+ when a keyed hash is present, including a value hash in a v0.5 value profile
sequenceFrom v0.4. A provider-assigned position such as a sequence number: a whole number from 0 to 9,007,199,254,740,991. Never a business identifier

deployment (optional; at least one field when present)

FieldConstraints
service1 to 128 chars, starts alphanumeric, then [A-Za-z0-9._:/+~-]
releaseSame label rules as service
commitSha7 to 64 hex chars (abbreviated through full SHA-256)

An empty deployment: {} is invalid; omit the object instead.

The shape grammar

schemaShape describes structure with six node kinds and no values: {"kind":"null"}, {"kind":"boolean"}, {"kind":"number"}, {"kind":"string"}, {"kind":"array","items":[...]} and {"kind":"object","fields":{...}}. Object keys are sorted; array item shapes are deduplicated.

A payload of {"id":"x","amount":1,"tags":["a"]} produces:

Code example

{  "kind": "object",  "fields": {    "amount": { "kind": "number" },    "id": { "kind": "string" },    "tags": { "kind": "array", "items": [{ "kind": "string" }] }  }}

The fingerprint is the SHA-256 of the shape's canonical signature, here {amount:number,id:string,tags:[string]}:

Code example

sha256:ea4cc46608c9c12efd2466a49251ad85b3893dcf7a846a938a3d00aad5785488

Two payloads with the same structure always share a fingerprint regardless of their values, key order, or array length. That is what lets Seamward detect contract drift by comparing fingerprints without ever seeing your data.

Value profile

A shape says a field is a number or a string. A value profile says what its values are like, using labels and bands that cannot be turned back into a value. It is what lets Seamward notice a change that keeps the shape and moves the meaning, such as an amount switching from minor to major units. See Value drift for what is detected and Redaction for how to control it.

The same payload, {"id":"x","amount":1,"tags":["a"]}, produces this profile:

Code example

{  "version": "1",  "paths": {    "$.amount": { "magnitudes": [0] },    "$.id": { "formats": ["other"], "stringLengths": [1] },    "$.tags": { "arrayLengths": [1] },    "$.tags[]": { "formats": ["other"], "stringLengths": [1] }  }}

paths is keyed by field path: $ is the payload, .name an object field, and [] the items of an array, all merged under one path. Booleans and nulls have no entry. Each entry holds only the properties that apply:

PropertyApplies toMeaning
formatsstringsOne or more labels from a fixed list; see below
stringLengthsstringsLength bands, in characters. A band is named by its lower bound: 0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000
magnitudesnumbersPowers of ten of the non-zero values, from -9 to 18: 1050 is 3, 10.5 is 1, 0.05 is -2
zeronumberstrue when a value was 0
fractionnumberstrue when a value was not a whole number
negativenumberstrue when a value was below zero
arrayLengthsarraysLength bands of the array, using the same bands as stringLengths
valueHashesstringsUp to 20 keyed sha256: hashes of distinct short values. Sent only when a collector is configured to; requires a hashNamespace
valuesOmittedstringstrue when the field held more than 20 distinct hashable values, so none are listed. Never present together with valueHashes

A string's format is the first label that matches, in this order:

LabelMatches
emptyThe empty string
iso-datetime2026-10-05T10:45:00Z, with optional seconds, fraction, and offset
iso-date2026-10-05
uuidFive hexadecimal groups in the 8-4-4-4-12 layout
integer-stringDigits with an optional leading minus sign
decimal-stringDigits, a full stop, and more digits, with an optional leading minus sign
currency-codeExactly three capital letters
emailText, @, and a dotted domain, with no spaces
urlBegins http:// or https://, with no spaces
otherAnything else, and any string longer than 256 characters

Limits: at most 256 paths, each at most 512 characters; nesting to depth 32; the first 100 items of an array; 10,000 values in one payload. A profile that left something out to stay within them carries "truncated": true. Unknown properties, unknown labels, and lengths that are not a band are rejected, so a profile has no place to put a value.

Validating an envelope

The contracts package that ships with the collector exports the schema, so you can validate your own envelopes before sending:

Code example

import { safeParseEnvelope } from "@seamward/contracts";
const result = safeParseEnvelope(envelope);if (!result.success) {  console.error(result.error.issues);}

Version compatibility

Seamward accepts envelopes 0.1 through 0.9. Existing collectors need no change: their output is read exactly as before.

EnvelopeEmitted byAdds
0.1Earliest collectorsThe base envelope
0.2PHP and Laravel collectors at 0.1.0-alpha.1Versioned operation identity, required payloadLocation, canonical keyed hashes
0.3Earlier Node.js collector alphaspayload.schemaInspected
0.4The current Node.js, PHP, and Laravel collectorsdisplayRouteTemplate, statusState, correlation.sequence, expectedIntervalMs
0.5The same collectors, when they send a value profilepayload.valueProfile; carries everything 0.4 does
0.6The same collectors, when an outcome reports an amount or stateoutcome.amount, outcome.state; carries everything 0.5 does
0.7The same collectors, when a response carries a deprecation noticetransport.deprecation; carries everything 0.6 does
0.8The same collectors, when a response carries a rate limit or a webhook reports a signature resulttransport.rateLimit, outcome.signatureValid; carries everything 0.7 does
0.9The same collectors, when an outcome names an accountoutcome.accountRefHash; carries everything 0.8 does

A field introduced in a later version is rejected on an earlier one. For example, a 0.3 envelope carrying statusState fails validation.

If you validate envelopes yourself with safeParseEnvelope, upgrade @seamward/contracts to a build that accepts the version before upgrading a collector that emits it. The same applies to the Seamward CLI: it validates API responses strictly, so a CLI released before a version rejects observation responses that carry it. Upgrade the CLI together with your collector.

Next steps