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.
| Field | Type | Required | Constraints |
|---|---|---|---|
envelopeVersion | string | yes | "0.5" when a value profile is sent, otherwise "0.4"; "0.1" to "0.3" remain readable |
eventId | string | yes | Starts with evt_; the idempotency key for ingestion |
tenantId | string | yes | Starts with ten_; send ten_authenticated, replaced from your credential |
environmentId | string | yes | Starts with env_; send env_authenticated, replaced from your credential |
integrationId | string | yes | Your public Integration key sw_int_... (resolved internally by Seamward) |
direction | enum | yes | inbound or outbound |
protocol | enum | yes | http-webhook, http-api, scheduled-feed, or queue |
occurredAt | string | yes | ISO 8601 datetime |
operationIdentityVersion | string | yes in v0.2+ | Literal "1" |
eventType | string | no | 1 to 128 chars matching [A-Za-z0-9._:-]; a low-cardinality event label such as candidate.completed, never an identifier |
deployment | object | no | Release context; see below |
correlation | object | yes | May be empty {}; see below |
contract | object | yes | See below |
transport | object | yes | See below |
payload | object | yes | Structural evidence; see below |
outcome | object | yes | See below |
transport
| Field | Type | Required | Constraints |
|---|---|---|---|
method | enum | yes | GET, POST, PUT, PATCH, DELETE |
routeTemplate | string | yes | Non-empty. A stable template like /webhooks/candidates, never a concrete id or query string |
displayRouteTemplate | string | no | From v0.4, HTTP protocols only. The same route with named parameters, such as /v1/payment_intents/:paymentIntentId |
payloadLocation | enum | yes in v0.2+ | request, response, or message |
statusCode | integer | yes | Exactly 0, or 100 through 599. 0 means no HTTP status is known; statusState says why |
statusState | enum | no | From v0.4. known, no-response, or unknown; see Status state |
durationMs | number | yes | Zero or greater |
attempt | integer | yes | 1 to 2,147,483,647 |
expectedIntervalMs | integer | no | From v0.4, scheduled-feed only. How often a run is expected, from 1 to 31,622,400,000 (366 days) |
deprecation | object | no | Only in v0.7. What the provider's response headers announced; see Deprecation |
rateLimit | object | no | Only 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.
| Field | Type | Constraints |
|---|---|---|
deprecated | boolean | Only ever true. The response carried a Deprecation header |
deprecatedAt | string | ISO 8601 UTC date-time. When the deprecation took or takes effect |
sunsetAt | string | ISO 8601 UTC date-time, from the Sunset header. When the operation stops working |
apiVersion | string | 1 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 nothingRate limit
transport.rateLimit records what a provider's response headers said about its allowance. It holds whole numbers only.
| Field | Type | Constraints |
|---|---|---|
limit | integer | 1 to 1,000,000,000,000. Requests allowed in the provider's current window |
remaining | integer | 0 to 1,000,000,000,000. Requests left in that window |
resetSeconds | integer | 0 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 meterDisplay 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-apiorhttp-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
:namewith{}gives exactly the envelope's normalizedrouteTemplate.
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:
statusState | statusCode | Meaning | outcome.accepted |
|---|---|---|---|
known | 100 to 599 | A real HTTP status was read | either |
no-response | 0 | No HTTP response was received, for example a refused connection | must be false |
unknown | 0 | A response may exist, but the collector could not read its status | either |
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 operation | direction | protocol | method | routeTemplate | payloadLocation |
|---|---|---|---|---|---|
| Outbound API call | outbound | http-api | HTTP verb | stable absolute path | response or request |
| Inbound API call | inbound | http-api | HTTP verb | stable absolute path | request |
| Inbound webhook | inbound | http-webhook | HTTP verb | stable absolute path | message |
| Queue publish | outbound | queue | POST | queue or topic label | message |
| Queue consume | inbound | queue | POST | queue, topic, or subscription label | message |
| Scheduled import | inbound | scheduled-feed | POST | feed or job label | message |
| Scheduled export | outbound | scheduled-feed | POST | feed or job label | message |
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)
| Field | Type | Required | Constraints |
|---|---|---|---|
storage | enum | yes | none (the default posture: the body was dropped in your process) or encrypted-object |
schemaFingerprint | string | yes | sha256: plus 64 hex chars |
schemaShape | shape | yes | The recursive structure description; grammar below |
schemaInspected | boolean | no | Only 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. |
valueProfile | object | no | Only in v0.5. Descriptions of field values, never the values; see Value profile. Rejected when schemaInspected is false |
redactionPolicyVersion | string | yes | The policy version active when the observation was built |
objectRef | string | only with encrypted-object | Present if and only if storage is encrypted-object |
contract
| Field | Type | Required | Constraints |
|---|---|---|---|
declaredVersion | string | no | The contract version you registered, when one exists |
observedFingerprint | string | yes | sha256: plus 64 hex chars |
outcome
| Field | Type | Required | Constraints |
|---|---|---|---|
accepted | boolean | yes | Whether the operation reached its intended result. Cannot be true when no response was received (see Status state) |
businessObjectType | string | no | Domain type such as candidate or payment |
businessObjectIdHash | string | no | Keyed hash of the business object id; never the raw id |
amount | object | no | Only 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 |
state | string | no | Only in v0.6. 1 to 64 characters of letters, digits, ., _ or -, such as paid. The state your application's record is in |
signatureValid | boolean | no | Only in v0.8. Whether your application's own check of a webhook's signature passed. A result only: no signature or secret is carried |
accountRefHash | string | no | Only 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)
| Field | Constraints |
|---|---|
traceId | Non-empty string; safe to send as-is because trace ids are operational, not personal |
sourceEventIdHash | Keyed hash of the provider's event id |
idempotencyKeyHash | Keyed hash of the idempotency key |
hashNamespace | Non-secret key-generation identifier; required in v0.2+ when a keyed hash is present, including a value hash in a v0.5 value profile |
sequence | From 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)
| Field | Constraints |
|---|---|
service | 1 to 128 chars, starts alphanumeric, then [A-Za-z0-9._:/+~-] |
release | Same label rules as service |
commitSha | 7 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:ea4cc46608c9c12efd2466a49251ad85b3893dcf7a846a938a3d00aad5785488Two 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:
| Property | Applies to | Meaning |
|---|---|---|
formats | strings | One or more labels from a fixed list; see below |
stringLengths | strings | Length bands, in characters. A band is named by its lower bound: 0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000 |
magnitudes | numbers | Powers of ten of the non-zero values, from -9 to 18: 1050 is 3, 10.5 is 1, 0.05 is -2 |
zero | numbers | true when a value was 0 |
fraction | numbers | true when a value was not a whole number |
negative | numbers | true when a value was below zero |
arrayLengths | arrays | Length bands of the array, using the same bands as stringLengths |
valueHashes | strings | Up to 20 keyed sha256: hashes of distinct short values. Sent only when a collector is configured to; requires a hashNamespace |
valuesOmitted | strings | true 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:
| Label | Matches |
|---|---|
empty | The empty string |
iso-datetime | 2026-10-05T10:45:00Z, with optional seconds, fraction, and offset |
iso-date | 2026-10-05 |
uuid | Five hexadecimal groups in the 8-4-4-4-12 layout |
integer-string | Digits with an optional leading minus sign |
decimal-string | Digits, a full stop, and more digits, with an optional leading minus sign |
currency-code | Exactly three capital letters |
email | Text, @, and a dotted domain, with no spaces |
url | Begins http:// or https://, with no spaces |
other | Anything 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.
| Envelope | Emitted by | Adds |
|---|---|---|
0.1 | Earliest collectors | The base envelope |
0.2 | PHP and Laravel collectors at 0.1.0-alpha.1 | Versioned operation identity, required payloadLocation, canonical keyed hashes |
0.3 | Earlier Node.js collector alphas | payload.schemaInspected |
0.4 | The current Node.js, PHP, and Laravel collectors | displayRouteTemplate, statusState, correlation.sequence, expectedIntervalMs |
0.5 | The same collectors, when they send a value profile | payload.valueProfile; carries everything 0.4 does |
0.6 | The same collectors, when an outcome reports an amount or state | outcome.amount, outcome.state; carries everything 0.5 does |
0.7 | The same collectors, when a response carries a deprecation notice | transport.deprecation; carries everything 0.6 does |
0.8 | The same collectors, when a response carries a rate limit or a webhook reports a signature result | transport.rateLimit, outcome.signatureValid; carries everything 0.7 does |
0.9 | The same collectors, when an outcome names an account | outcome.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
- Ingest observations: sending envelopes to Seamward.
- Direct REST integration: building envelopes without the collector.
- Redaction: what leaves your process and what never does.
