procurement-ingest/docs/shoc-webhook-contract.md
Adam Moussa 0c1dcd7844
fix(terraform): grant shoc-backend-staging HMAC and API access (PLAT-211) (#212)
Terraform still pinned only shoc-backend-dev, so the next ingest apply would drop the live staging HMAC grant.
2026-09-18 18:27:55 +00:00

11 KiB

SHOC Work-Order Webhook — Delivery Contract (v1)

Status: DRAFT — for SHOC team (Luby) review. Rev 2026-07-23 (supersedes the 2026-07-16 draft: producer account corrected to seahaven-prod; reconciliation backstop changed to the new read API; §4.1 unknown status note; §3 write_origin forward-compat note. Sections 2, 5, 6, 7, and 10 are unchanged from the 07-16 draft). Producer: workorder-shoc-emitter Lambda, Sea Haven seahaven-prod AWS account (011934824531, us-east-1). Mgmt-account procurement-ingest stacks were deleted in PLAT-67 (2026-08-05); this feed, its secret, and its streams exist only in seahaven-prod. Consumer: SHOC backend (.NET 8), initially https://api.dev.seahaven.com. Endpoint path is SHOC's choice — suggested POST /api/webhooks/work-orders; map entities onto the work-orders domain from shoc-backend PR #10.

1. Overview

Every work-order mutation the procurement-ingest pipeline writes to DynamoDB is pushed to SHOC as an HTTPS POST within seconds. The feed is driven by DynamoDB Streams, so events are emitted in the exact order the pipeline committed them, per work order. DynamoDB remains the source of truth; this webhook is a realtime feed. The reconciliation backstop (and the initial-history load — the feed starts at activation time, not from history) is the procurement read API (GET /work-orders, GET /work-orders/{id}/comments, IAM SigV4 — see its OpenAPI doc). Mgmt stacks are gone (PLAT-67); SHOC should retire SyncController's DynamoDB scan of the old management account and use the read API only. SyncVendorReplies should be dropped on the SHOC side — the VendorReplies table is dead (its writer was deleted), and no vendor_reply webhook event exists or is planned.

2. Transport

  • HTTPS POST, Content-Type: application/json; charset=utf-8, body ≤ 256 KB.
  • Producer timeout is 10 seconds. Respond 2xx as fast as possible; if your processing is slow, accept-and-enqueue internally rather than processing inline.

3. Event envelope

{
  "schema_version": 1,
  "delivery_id": "4b7c2f0e-...",
  "event_type": "work_order.updated",
  "occurred_at": "2026-07-16T14:03:22.114208+00:00",
  "source": "procurement-ingest/workorder-shoc-emitter",
  "replay": false,
  "data": { ... }
}
Field Meaning
schema_version Integer. Breaking payload changes increment it; SHOC should reject versions it doesn't know.
delivery_id Unique per source event, stable across producer retries — your idempotency key. Persist it; ignore any delivery whose id you've already processed.
event_type See §4.
occurred_at ISO 8601 with UTC offset (+00:00), when the pipeline committed the write.
replay true when re-sent by the operator replay tool after an outage. Same idempotency rules apply.
data Event-type-specific body, §4.

Forward-compat (phase-2 write-back): when SHOC later gains write endpoints on the read API, records SHOC itself wrote will carry a write_origin attribute (e.g. "shoc-write-api"), and the emitter will skip them so SHOC never receives an echo of its own write. Nothing to build now — just don't reject envelopes if a data.write_origin field appears later.

4. Event types

4.1 work_order.created / work_order.updated / work_order.cancelled

Emitted from WorkOrders table writes: created on first insert, updated on any subsequent change, cancelled when wo_status transitions to cancelled (a cancelled event is a specialization of updated — same body). data is the full current work-order state (not a diff); fields absent from the source email are null:

{
  "work_order_id": "11144580730",
  "wo_status": "assigned",
  "description": "Dock door 14 won't close",
  "customer": "AMAZON",
  "site_code": "JFK8",
  "building": "JFK8",
  "address": "546 Gulf Ave, Staten Island, NY 10314",
  "severity": "3-Normal",
  "priority": "Medium",
  "assigned_to": "Sea Haven Industries",
  "date_reported": "2026-07-14T09:12:00",
  "scheduled_start": "2026-07-17T08:00:00",
  "due_date": "2026-07-21T17:00:00",
  "record_type": "new_work_order",
  "created_at": "2026-07-16T14:03:22.114208+00:00",
  "updated_at": "2026-07-16T14:03:22.114208+00:00"
}

wo_status ∈ new | assigned | in_progress | on_hold | completed | cancelled | unknown. record_type (the triggering email's type) ∈ new_work_order | update | comment | cancellation.

unknown is a real value the extractor emits when the source email doesn't state a status — SHOC must define an explicit mapping for it (and defensively for any unrecognized future value) rather than letting it fall through a switch. Field names and enums in this section mirror the producer's write path, lambdas/wo/email_processor/persistence.py (+ prompts.py for enums) on main — that code is the authoritative source if this doc ever drifts.

4.2 work_order.comment_added

Emitted from WorkOrderComments inserts — one per source email (comments, but also update/cancellation event records):

{
  "work_order_id": "11144580730",
  "comment_id": "11144580730#2026-04-27T23:51:48#a1b2c3d4e5f6",
  "record_type": "comment",
  "commenter": "APM Technician",
  "text": "Vendor dispatched, ETA tomorrow AM.",
  "created_at": "2026-04-27T23:51:48",
  "ingested_at": "2026-07-16T14:03:22.114208+00:00"
}

comment_id is unique per source email and stable across retries — use it (or delivery_id) for dedupe.

5. Ordering and delivery semantics

  • At-least-once. Duplicates are possible on retry; dedupe on delivery_id.
  • Per-work-order, per-event-family ordering is guaranteed on the live feed: all work_order.* state events for a given work_order_id arrive in commit order (a cancelled never precedes its created). Comment events are likewise ordered among themselves per work order. Caveat: this holds for delivered events. A rare non-retryable 4xx on one event (a contract bug) parks that event and lets later events proceed, so the receiver can in principle see a work_order.updated/cancelled for a WO whose created was parked. Because every work_order.* body is full current state (not a diff), the two requirements below make this safe regardless.
  • Requirement — skeleton-upsert on ANY unknown work_order_id: on any work_order.* event (state or comment) referencing a work_order_id you have not seen, upsert the record from the event's own data (or a skeleton for a bare comment) rather than dropping it. Full-state bodies mean a later event fully reconstructs a WO whose created never arrived — the same out-of-order semantics the pipeline itself uses for source emails.
  • Requirement — monotonicity (do not regress state): ignore any work_order.* state event whose data.updated_at is older than the updated_at already stored for that WO. This makes an operator replay (see §8) or a rare out-of-order delivery idempotent and non-regressing: a stale snapshot can never overwrite newer state. Comments are append-only (dedupe on comment_id), so this applies only to WO state events.
  • Cross-family ordering is NOT guaranteed: a comment_added may occasionally arrive before the created for its work order (separate streams) — covered by the skeleton-upsert requirement above.
  • Expected volume ≈ 22,900 events/month (~760/day); >90% are comment events. Bursts of a few events/second are possible.

6. Authentication — HMAC signature

Every request carries:

X-SH-Timestamp: 1784642602          (unix seconds, producer clock)
X-SH-Key-Id: 2026-07-20T00
X-SH-Signature: v1=hex(HMAC_SHA256(secret, "{timestamp}.{raw_body}"))

Verification requirements (fail closed — reject with 401 on any failure):

  1. Reject if |now - X-SH-Timestamp| > 300s (replay window).
  2. Look up the secret for X-SH-Key-Id from the shared secret material (§6.1). Reject unknown key ids.
  3. Recompute HMAC-SHA256(secret, timestamp + "." + raw_body) over the raw request bytes (before any JSON parsing/re-serialization) and compare constant-time against the v1= value.

6.1 Key material and rotation

The secret lives in seahaven-prod (011934824531) Secrets Manager: workorder-ingest/shoc-webhook-hmac, encrypted with a dedicated customer-managed KMS key so it is readable cross-account. The SHOC backend roles (arn:aws:iam::396287094661:role/shoc-backend-dev and arn:aws:iam::396287094661:role/shoc-backend-staging) are granted secretsmanager:GetSecretValue + kms:Decrypt via resource policies — exact role ARNs only; a future shoc-backend-prod role is a deliberate, individually-reviewed policy addition (no wildcard/prefix trust). Secret value shape:

{
  "keys": [
    { "kid": "2026-07-20T00", "secret": "<64 hex chars>" },
    { "kid": "2026-06-20T00", "secret": "<64 hex chars>" }
  ]
}
  • Rotation is automated (30-day schedule): a new key is prepended, the previous key is kept for one overlap cycle.
  • The producer always signs with keys[0]. The receiver must accept any listed kid.
  • Receiver must re-fetch the secret at most every 5 minutes (cache TTL ≤ 300s) so rotation propagates with zero downtime. Also re-fetch immediately on an unknown-kid before rejecting.

7. Response contract

Receiver response Producer behavior
2xx Delivered. Body ignored.
429, 5xx, timeout, connection error Retried with in-order blocking (that work order's events queue behind it) for up to 24 hours; then parked for operator replay.
Any other 4xx Not retried — parked immediately and alarmed on our side. A 4xx means a contract bug; expect us to call you.

8. Backstop / replay

If SHOC is down longer than the retry window or deliveries are parked, an operator replay tool re-sends events rebuilt from DynamoDB (marked "replay": true). Because DynamoDB stays the source of truth, the procurement read API can also fully reconcile at any time (paginated GET /work-orders + per-WO comments). Missed webhooks are therefore an availability inconvenience, never data loss.

9. SHOC-side checklist

  • Endpoint path confirmed + implemented against PR #10 entity model
  • Raw-body HMAC verification, constant-time compare, ±300s timestamp window, fail-closed
  • Cross-account secret fetch with ≤5-min cache + refresh-on-unknown-kid
  • Dedupe store on delivery_id (and/or comment_id)
  • Skeleton-upsert on ANY unknown work_order_id (state events too, not just comment-before-create)
  • Monotonicity guard: ignore WO state events whose data.updated_at is older than stored (§5)
  • Fast 2xx ack (<10s), internal queue if processing is slow
  • Timestamp parsing accepts +00:00 offsets
  • Reject unknown schema_version
  • Explicit mapping for wo_status: unknown (and a safe default for unrecognized future values)
  • Initial history loaded via the read API before relying on the feed (feed starts at activation, not from history)
  • SyncVendorReplies dropped (dead table; no vendor-reply event exists)

10. Environments

Env URL Status
dev https://api.dev.seahaven.com/<path TBD> first target (prod data in dev accepted 2026-07-16)
staging https://api.staging.seahaven.com/<path TBD> when staging deploys
prod TBD when SHOC prod exists

The target URL is producer-side config (per-env), so promotion is a config change, not a code change.