Ingest observations

POST /ingest accepts batches of redacted observation envelopes and returns a per-item accept or reject result. The Node.js, PHP, and Laravel collectors call it for you; consult this page when debugging delivery or implementing it in another language.

Endpoint

Code example

POST https://api.seamward.com/ingestContent-Type: application/json

Authentication and signing are on the authentication page; every request carries the three headers described there.

Request body

The body is a JSON object with one key, envelopes: an array of observation envelopes. The observation envelope page documents every field. Three fields behave specially at the ingestion boundary:

  • integrationId carries your public Integration key (sw_int_...). Seamward resolves it to the internal Integration owned by your credential; a key that does not belong to your application rejects that envelope.
  • tenantId and environmentId are placeholders. Send the literal values ten_authenticated and env_authenticated (exactly what the collectors send); Seamward replaces them with the scope bound to your credential, so an envelope can never write into another workspace.
  • eventId is the idempotency key. Redelivering a batch with the same event ids is safe: duplicates are skipped, not duplicated.
  • transport.statusCode accepts exactly 0 or 100 through 599. Use 0 only when an outbound attempt received no protocol response. A valid status-zero envelope is accepted and persisted, then appears as a transport_error observation.

Keep batches at or below 50 envelopes (the collector's own limit) and under 1 MiB of JSON.

Code example

BODY='{"envelopes":[{"envelopeVersion":"0.2","eventId":"evt_01J8ZQ4X2KD9","tenantId":"ten_authenticated","environmentId":"env_authenticated","integrationId":"sw_int_c9K2mQ7xLp4wR8tZ","direction":"inbound","protocol":"http-webhook","occurredAt":"2026-08-15T09:24:11.000Z","operationIdentityVersion":"1","eventType":"candidate.create","correlation":{"sourceEventIdHash":"sha256:5f3a9c0d7b2e4a618d2c6b4a9e1f3d570a1b2c3d4e5f60718293a4b5c6d7e8f9","hashNamespace":"candidate-api-key-2026-08"},"contract":{"observedFingerprint":"sha256:9c4f2a7d8e1b6a3c5d9e0f2a4b6c8d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a0b2c"},"transport":{"method":"POST","routeTemplate":"/webhooks/candidates","payloadLocation":"message","statusCode":202,"durationMs":48,"attempt":1},"payload":{"storage":"none","schemaFingerprint":"sha256:9c4f2a7d8e1b6a3c5d9e0f2a4b6c8d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a0b2c","schemaShape":{"kind":"object","fields":{"candidateId":{"kind":"string"},"status":{"kind":"string"}}},"redactionPolicyVersion":"seamward-default-v1"},"outcome":{"accepted":true,"businessObjectType":"candidate","businessObjectIdHash":"sha256:8d2c6b4a9e1f3d575f3a9c0d7b2e4a610a1b2c3d4e5f60718293a4b5c6d7e8f9"}}]}'
TS="$(date +%s)"SIG="t=$TS,v1=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SEAMWARD_INGEST_TOKEN" -hex | sed 's/^.* //')"
curl -sS "https://api.seamward.com/ingest" \  -H "Content-Type: application/json" \  -H "X-Seamward-Connection: $SEAMWARD_CONNECTION_KEY" \  -H "Authorization: Bearer $SEAMWARD_INGEST_TOKEN" \  -H "X-Seamward-Signature: $SIG" \  -d "$BODY"

Seamward supports all four protocols across Integrations, but one authenticated batch is bound to one Integration's direction and protocol. Every item in that batch needs the stable operation identity that belongs to that Integration:

Example operationDirectionProtocolTargetPayload locationExample status
Create a provider recordoutboundhttp-api/v1/candidatesresponse201
Receive a provider webhookinboundhttp-webhook/webhooks/candidatesmessage202
Consume a queue messageinboundqueuecandidate-eventsmessage202
Export a nightly feedoutboundscheduled-feednightly-candidate-exportmessage200

Responses

Success is 202 when every envelope was accepted, 207 when the batch was mixed. Both return the same shape, and results aligns by index with your envelopes array:

Code example

{  "accepted": 1,  "persisted": 1,  "results": [    { "index": 0, "accepted": true, "eventId": "evt_01J8ZQ4X2KD9" },    {      "index": 1,      "accepted": false,      "issues": ["integration does not match credential"]    }  ]}

Accepted items echo their eventId; rejected items carry issues, an array of human-readable reasons:

IssueCause
integration does not match credentialThe envelope targets a different Integration from the Connection key and ingest token
tenant does not match credential / environment does not match credentialScope fields disagree with the credential (rare with a correct client)
integration is not availableThe integration exists but its recorded direction, protocol, or environment disagrees with this envelope
Field-level schema messagesThe envelope failed validation; each message names one violation

Errors

StatusBodyMeaning
400{"error":"expected { envelopes: [...] }"}The body is not an object with an envelopes array
401{"error":"unauthenticated"} (with optional reason)Authentication failed; see signature failures
409{"error":"integration_not_available"}Whole-batch race on integration ownership; safe to retry

Workload admission

Authenticated ingestion uses shared, persistent 60-second windows: at most 600 requests and 6,000 attempted observations per integration, and 30,000 attempted observations per workspace. Rejected items and duplicate event IDs count as attempted work. A batch that exceeds any budget returns 429 with {"error":"workload_rate_limited"} and a Retry-After header in seconds. No observations from that batch are persisted. Wait for the indicated delay, then retry with the same event IDs and bounded backoff. Concurrent API processes share these budgets; restarting a process does not reset them.

Retries

  • Honour Retry-After on 429. Retry documented 5xx and 409 responses with bounded backoff; eventId idempotency makes redelivery safe. Collectors also treat intermediary throttling responses as retryable without promising an endpoint-specific 429 schema.
  • Do not retry 400 or 401 unchanged. Fix the body or credentials first.
  • A 207 needs no retry for its accepted items; fix and resend only the rejected ones.

Next steps

Monthly mode allowances

Sandbox has a separate account allowance, initially 10,000 accepted observations per UTC calendar month. Live uses the account plan allowance. Switching the console view does not change an ingest credential's mode. The server uses its stored connection binding; an envelope cannot choose a cheaper mode.

Enforcement remains off until the published grace period ends. Once active, new Sandbox observations in the whole batch must fit the remaining Sandbox account allowance. Live observations remain metered without this quota rejection. Duplicate event IDs do not consume the allowance again. An over-limit Sandbox batch persists no observations and enqueues no analysis, even if some new items would fit. The response is 403, never a temporary throttling response:

Code example

{  "error": "observation_monthly_quota_exhausted",  "mode": "sandbox",  "period": "utc_calendar_month",  "resetAt": "2026-10-01T00:00:00.000Z",  "retryable": false,  "batchPersisted": 0}

Do not retry this batch in a loop. Updated Node, PHP, and Laravel collectors count the rejected batch and queued observations in rejected and its quotaRejected subset. They discard telemetry queued for the exhausted period and reject new observations locally until resetAt; the customer's application continues normally. quotaResetAtSeconds is the UTC reset as Unix seconds, or 0 when no quota pause is active. Delivery resumes automatically for new traffic after that date; discarded observations are not restored.

Previously released Node 0.1.0-alpha.28 and PHP/Laravel v0.1.0-alpha.1 collectors discard each 403 batch and continue sending later evidence without waiting for reset. Upgrade to the quota-aware release before enforcement begins. Malformed or non-quota 403 responses do not install a monthly pause. Minute workload throttling continues to use 429 and Retry-After, preserving bounded retry.

An account owner or billing administrator can inspect remaining allowances through the account usage API. Usage survives evidence retention, connection deletion, and workspace moves.

Sandbox minute protection

Authenticated Sandbox ingestion is limited across the admission account to 1,000 submitted envelopes and 100 requests per minute. Invalid items, duplicate items and malformed frames still consume the applicable submitted workload budget. This protection is separate from the monthly allowance, which charges only newly accepted valid observations.

429 sandbox_observation_rate_limited and 429 sandbox_request_rate_limited include Retry-After seconds. Wait at least that long and reduce batch frequency. Do not rotate keys or spread submissions across workspaces to evade the pooled limit. Live continues to use the existing workload protection. Read account limits.