# SYNCED COPY — source of truth is docs/api/openapi.yaml. This file is
# regenerated automatically by scripts/sync-openapi.mjs on every build
# (npm prebuild hook) — do not hand-edit.
openapi: 3.1.0
info:
  title: Seven16 Email API
  version: "1.0.0"
  description: |
    Server-to-server API for the Seven16 family integration mesh and headless
    orchestration (AI agents, sibling products).

    ## Authentication (all /v1 endpoints)
    Every request carries six headers (family API contract §1.5):

    | Header | Value |
    |---|---|
    | `X-Seven16-Product` | your product slug (e.g. `bindlab`) |
    | `X-Seven16-Environment` | `production` or `sandbox` |
    | `X-Seven16-Api-Key` | `s16_live_<slug>_<random>` or `s16_test_…` |
    | `X-Seven16-Timestamp` | ISO-8601, within ±5 minutes of server time |
    | `X-Seven16-Nonce` | unique per request (single-use, replay-rejected) |
    | `X-Seven16-Signature` | `sha256=<hmac>` |

    **Signature**: HMAC-SHA256 keyed by the raw API key over
    `METHOD\nPATH\nTIMESTAMP\nNONCE\nSHA256(BODY)` (empty-string body hash for
    GET/DELETE without a body — sign the exact bytes you send).

    Worked example (Node.js) for `POST /v1/imports`:
    ```js
    const crypto = require("crypto");

    const method = "POST";
    const path = "/v1/imports";
    const timestamp = new Date().toISOString();
    const nonce = crypto.randomUUID();
    const body = JSON.stringify({ product_tenant_id: "abc123", csv: "email\njane@acme.com" });
    const bodyHash = crypto.createHash("sha256").update(body).digest("hex");

    const stringToSign = `${method}\n${path}\n${timestamp}\n${nonce}\n${bodyHash}`;
    const signature = crypto
      .createHmac("sha256", process.env.SEVEN16_API_KEY)
      .update(stringToSign)
      .digest("hex");

    // Send as: X-Seven16-Signature: sha256=${signature}
    ```
    PATH is the path only (no host/query string). Recompute the signature on
    every request — timestamps and nonces are single-use.

    **Tenancy**: every call passes YOUR `product_tenant_id` (body field on
    writes, query param on reads). The engine resolves the authoritative
    engine tenant via its tenant-mapping table; a request-supplied
    `engine_tenant_id` is only ever used as an anti-impersonation check.

    **Idempotency**: POST/PATCH/DELETE endpoints accept an `Idempotency-Key`
    header. Replays with the same key + body return the stored response;
    same key + different body returns 422.

    **Rate limit**: 120 requests/minute per API key. 429 responses carry a
    `Retry-After` header (seconds).

    **Errors**: every non-2xx response is `{ "ok": false, "error": "<code>" }`
    with an optional `detail` string.

    **Scopes**: keys are issued with an explicit scope list. Missing scope →
    403 `missing_scope:<scope>`.
servers:
  - url: https://app.seven16email.com/api
    description: Production (api.seven16email.com + sandbox.seven16email.com hosts land pre-go-live per D-E035)
tags:
  - name: Campaigns
  - name: Templates
  - name: Sequences
  - name: Contacts & Imports
  - name: Transactional
  - name: Messages
  - name: Domains & Senders
  - name: Family Integration
  - name: Analytics
  - name: Webhooks
  - name: System

paths:
  /health:
    get:
      tags: [System]
      summary: Liveness probe (public, no auth)
      responses:
        "200":
          description: Process + database healthy
        "503":
          description: Database unreachable

  /v1/campaigns:
    get:
      tags: [Campaigns]
      summary: List campaigns
      description: "Scope: `campaigns:read`"
      parameters:
        - $ref: "#/components/parameters/ProductTenantIdQuery"
        - in: query
          name: status
          schema: { type: string, enum: [draft, preflight, scheduled, sending, sent, cancelled] }
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        "200": { description: "`{ ok, campaigns: [...] }`" }
    post:
      tags: [Campaigns]
      summary: Create a draft campaign
      description: |
        Scope: `campaigns:write`. Creates a DRAFT — nothing sends until
        `POST /v1/campaigns/{id}/launch`. Classification (marketing /
        operational / transactional) derives from `message_purpose` via the
        purpose-aware send rules; non-marketing purposes require
        `audience_reason`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, name, sender_profile_id, audience_mode]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                name: { type: string }
                sender_profile_id: { type: string, format: uuid }
                template_id: { type: string, format: uuid, description: Template OR subject+body_html required }
                subject: { type: string }
                preheader: { type: string }
                body_html: { type: string }
                campaign_goal:
                  type: string
                  enum: [newsletter, announcement, product_launch, webinar_invite, trade_show_followup, demo_request, appointment_request, reengagement, cross_sell, renewal_reminder, partner_activation, other]
                audience_mode: { type: string, enum: [list, segment, tags] }
                list_id: { type: string, format: uuid }
                segment_id: { type: string, format: uuid }
                tag_ids: { type: array, items: { type: string, format: uuid } }
                message_purpose: { type: string, default: marketing_campaign }
                urgency: { type: string, enum: [low, normal, important, urgent, emergency], default: normal }
                audience_reason: { type: string, description: Required for non-marketing purposes }
      responses:
        "201": { description: "`{ ok, campaign_id, status: draft, email_type }`" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { description: list/segment/sender/template not found in tenant }

  /v1/campaigns/{id}:
    get:
      tags: [Campaigns]
      summary: Campaign detail + send-status counts
      description: "Scope: `campaigns:read`"
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ProductTenantIdQuery"
      responses:
        "200": { description: "`{ ok, campaign, send_counts }`" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/campaigns/{id}/launch:
    post:
      tags: [Campaigns]
      summary: Launch (or schedule) a draft campaign
      description: |
        Scope: `campaigns:launch`. Same engine as the dashboard send button:
        audience resolution, purpose-aware suppression filter, A/B variant
        assignment, send materialization. Worker picks rows up within ~60s.
        Every send still passes the compliance gate at render time.
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                schedule_for: { type: string, format: date-time, description: Omit to send now }
      responses:
        "202": { description: "`{ ok, campaign_id, queued, skipped, scheduled_for, ab_variants }`" }
        "409": { description: campaign_already_<status> }
        "422": { description: audience_resolved_to_zero_contacts | sender_missing_verified_domain | all_contacts_suppressed_or_bounced }

  /v1/templates:
    get:
      tags: [Templates]
      summary: List templates
      description: "Scope: `templates:read`"
      parameters:
        - $ref: "#/components/parameters/ProductTenantIdQuery"
        - in: query
          name: status
          schema: { type: string, enum: [draft, active, archived] }
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        "200": { description: "`{ ok, templates: [...] }`" }
    post:
      tags: [Templates]
      summary: Create a template
      description: "Scope: `templates:write`. Subject/body accept Liquid merge fields (`{{ contact.first_name }}`, `{{ unsubscribe_url }}`, …)."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, name, subject, body_html]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                name: { type: string }
                subject: { type: string }
                body_html: { type: string }
                body_text: { type: string }
                preheader: { type: string }
                template_type: { type: string, enum: [marketing, transactional, sequence], default: marketing }
                status: { type: string, enum: [draft, active], default: active }
      responses:
        "201": { description: "`{ ok, template_id, status, version }`" }

  /v1/templates/{id}:
    get:
      tags: [Templates]
      summary: Template detail (full body)
      description: "Scope: `templates:read`"
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ProductTenantIdQuery"
      responses:
        "200": { description: "`{ ok, template }`" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Templates]
      summary: Update name/subject/preheader/body/status
      description: "Scope: `templates:write`"
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id]
              properties:
                product_tenant_id: { type: string }
                name: { type: string }
                subject: { type: string }
                preheader: { type: string }
                body_html: { type: string }
                body_text: { type: string }
                status: { type: string, enum: [draft, active, archived] }
      responses:
        "200": { description: "`{ ok, template_id, status }`" }
    delete:
      tags: [Templates]
      summary: Archive a template (soft delete)
      description: "Scope: `templates:write`. Archives rather than hard-deletes so send history and sequence steps keep their references."
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
      responses:
        "200": { description: "`{ ok, template_id, status: archived }`" }

  /v1/sequences:
    get:
      tags: [Sequences]
      summary: List sequences
      description: "Scope: `sequences:read`"
      parameters:
        - $ref: "#/components/parameters/ProductTenantIdQuery"
        - in: query
          name: status
          schema: { type: string, enum: [draft, active, paused, archived] }
      responses:
        "200": { description: "`{ ok, sequences: [...] }`" }
    post:
      tags: [Sequences]
      summary: Create a sequence with its steps in one call
      description: |
        Scope: `sequences:write`. Steps are ordered by array position (linear
        flows; condition branching is dashboard-only). `delay_amount: 0` fires
        on the next worker tick (~60s) — the "email 1 within 5 minutes"
        pattern. Pass `activate: true` to go live immediately (requires a
        sender profile when any step sends email); default is draft.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, name, steps]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                name: { type: string }
                description: { type: string }
                sender_profile_id: { type: string, format: uuid }
                trigger_type: { type: string, default: manual_enrollment }
                exit_on_unsubscribe: { type: boolean, default: true }
                exit_on_bounce: { type: boolean, default: true }
                exit_on_complaint: { type: boolean, default: true }
                activate: { type: boolean, default: false }
                steps:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    type: object
                    required: [step_type]
                    properties:
                      step_type:
                        type: string
                        enum: [send_email, wait, apply_tag, remove_tag, update_field, update_lifecycle_stage, notify_owner, create_task, create_opportunity, end_sequence, update_subscription, add_suppression, call_product_webhook]
                      template_id: { type: string, format: uuid, description: Required for send_email }
                      delay_amount: { type: integer, minimum: 0 }
                      delay_unit: { type: string, enum: [minutes, hours, days, business_days] }
                      action_config: { type: object }
      responses:
        "201": { description: "`{ ok, sequence_id, status, steps: [{step_id, step_order, step_type}] }`" }
        "404": { description: template_not_found | sender_profile_not_found }

  /v1/sequences/{id}:
    get:
      tags: [Sequences]
      summary: Sequence detail including ordered steps
      description: "Scope: `sequences:read`"
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ProductTenantIdQuery"
      responses:
        "200": { description: "`{ ok, sequence, steps }`" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Sequences]
      summary: Update name/description or transition status
      description: |
        Scope: `sequences:write`. Transitions: draft→active, active→paused,
        paused→active, any→archived. Activation requires ≥1 step (+ sender
        profile if any step sends email).
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id]
              properties:
                product_tenant_id: { type: string }
                name: { type: string }
                description: { type: string }
                status: { type: string, enum: [active, paused, archived] }
      responses:
        "200": { description: "`{ ok, sequence_id, status }`" }
        "409": { description: invalid_status_transition | sequence_has_no_steps | sender_profile_required_to_activate }

  /v1/enrollments:
    post:
      tags: [Sequences]
      summary: Enroll a contact into an active sequence
      description: "Scope: `sequences:enroll`. Contact is found-or-created by email in the resolved tenant. 409 `already_enrolled` on duplicates."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, sequence_id, email]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                sequence_id: { type: string, format: uuid }
                email: { type: string, format: email }
                first_name: { type: string }
                last_name: { type: string }
                title: { type: string }
                phone: { type: string }
      responses:
        "201": { description: "`{ ok, enrollment_id, contact_id, sequence_id, next_action_at }`" }
        "409": { description: already_enrolled | sequence_not_active | sequence_has_no_steps }

  /v1/imports:
    post:
      tags: [Contacts & Imports]
      summary: Batch contact import (CSV)
      description: "Scope: `imports:write`. Runs the same stage→validate→commit pipeline as the dashboard importer, synchronously. Header row required."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, csv]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                csv: { type: string, description: CSV text with header row }
                name: { type: string, description: Job label }
                operation:
                  type: string
                  enum: [create, update, upsert, suppression_import, audience_only, verification_only]
                  default: upsert
                source_slug: { type: string, default: api_import }
                compliance:
                  type: object
                  properties:
                    consent_basis: { type: string }
                    marketing_allowed: { type: boolean }
                    require_verification: { type: boolean }
                    source_description: { type: string }
      responses:
        "200": { description: Job summary with row counts }
        "413": { description: too_many_rows — use smaller batches }

  /v1/transactional:
    post:
      tags: [Transactional]
      summary: Send a one-off transactional email
      description: |
        Scope: `email:send_one:create`. Template-based (template must exist in
        the tenant). Contact is found-or-created by email. Queued for the
        worker (~60s); passes the compliance gate — hard deliverability blocks
        (bounce/complaint) always apply even for transactional sends.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, template_id, email]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                template_id: { type: string, format: uuid }
                email: { type: string, format: email }
                sender_profile_id: { type: string, format: uuid, description: Defaults to tenant default sender }
                first_name: { type: string }
                last_name: { type: string }
                title: { type: string }
                phone: { type: string }
      responses:
        "202": { description: "`{ ok, send_id, status: scheduled }`" }

  /v1/tenants/link:
    post:
      tags: [Family Integration]
      summary: One-click family tenant provisioning
      description: |
        Scope: `tenants:provision`. A satellite product (holding its own
        product-level key — no per-producer keys) links one of its tenants to
        an authoritative Seven16 Email engine tenant. Find-or-create + upsert,
        idempotent on (product_key, environment, product_tenant_id). The
        engine tenant is created without an auth member; mesh writes use the
        service role directly, so this works immediately — membership
        reconciles when the producer later signs into Email with `email`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, email]
              properties:
                product_tenant_id: { type: string }
                email: { type: string, format: email, description: Becomes the Email owner identity }
                org_name: { type: string }
                producer_name: { type: string }
      responses:
        "200": { description: "`{ ok, engine_tenant_id, already_linked }`" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/outcomes:
    post:
      tags: [Family Integration]
      summary: Record a downstream insurance outcome for attribution
      description: |
        Scope: `outcomes:write`. Posts a quote/submission/bind/premium event,
        attributed to the contact that received the campaign (matched by
        `contact_email` or `dot_number`). Upserted on
        (tenant, source_system, source_event_id) for idempotent sync — Email
        stays the activation layer; this is the connected-outcomes side of
        attribution (native engagement tracking is untouched).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, outcome_type]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                contact_email: { type: string, format: email, description: "One of contact_email / dot_number required" }
                dot_number: { type: integer }
                outcome_type: { type: string, enum: [quote, submission, bind, premium] }
                premium_cents: { type: integer }
                premium_amount: { type: number, description: Dollars — takes precedence over premium_cents }
                value_cents: { type: integer }
                occurred_at: { type: string, format: date-time, description: Defaults to now }
                source_event_id: { type: string, description: Idempotency key for the outcome }
      responses:
        "200": { description: "`{ ok, outcome_id, contact_matched }`" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/domains:
    get:
      tags: [Domains & Senders]
      summary: List the tenant's sending domains
      description: "Scope: `domains:manage`. Includes per-record DNS validity so the caller's UI can show which record is still wrong."
      parameters:
        - $ref: "#/components/parameters/ProductTenantIdQuery"
      responses:
        "200": { description: "`{ ok, domains: [{ domain_id, domain, status, verified_at, last_checked_at, records }] }`" }
    post:
      tags: [Domains & Senders]
      summary: Provision a tenant sending domain
      description: |
        Scope: `domains:manage`. Idempotent on (tenant, domain) — reposting an
        existing domain returns its current records. Own-domain posture
        (non-negotiable): platform domains and freemail domains (gmail.com,
        etc.) are rejected with 422 — every tenant sends from a domain they
        own. Returns the DNS records to add.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, domain]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                domain: { type: string }
      responses:
        "200": { description: "`{ ok, domain_id, status, dns_records: [{ type, host, value, purpose }], already_existed }`" }
        "409": { description: domain_claimed_by_another_tenant }
        "422": { description: platform_domain_not_allowed | freemail_domain_not_allowed }

  /v1/domains/{id}/verify:
    post:
      tags: [Domains & Senders]
      summary: Force a DNS recheck for a sending domain
      description: "Scope: `domains:manage`. \"I added the records, check now\" — forces a provider recheck and persists per-record results."
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
      responses:
        "200": { description: "`{ ok, domain_id, status, dns_records: [{ type, host, value, purpose, valid }] }`" }
        "404": { description: domain_not_found }
        "409": { description: domain_missing_provider_record }

  /v1/senders:
    get:
      tags: [Domains & Senders]
      summary: List the tenant's sender identities
      description: "Scope: `senders:manage`. For From-address pickers."
      parameters:
        - $ref: "#/components/parameters/ProductTenantIdQuery"
      responses:
        "200": { description: "`{ ok, senders: [{ sender_id, email, name, reply_to, status, is_default, domain_status }] }`" }
    post:
      tags: [Domains & Senders]
      summary: Create a sender identity on a verified domain
      description: |
        Scope: `senders:manage`. 422 unless the address's domain is a VERIFIED
        domain belonging to this tenant (own-domain posture — no shared-domain
        sending, ever). `physical_address` is the CAN-SPAM §5(a)(5) postal
        address stamped on marketing footers — falls back to the tenant
        profile's address; 422 if neither exists. Idempotent on
        (tenant, domain, local_part).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, email, name]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                email: { type: string, format: email }
                name: { type: string }
                reply_to: { type: string, format: email }
                physical_address: { type: string }
      responses:
        "200": { description: "`{ ok, sender_id, already_existed }`" }
        "422": { description: domain_not_found_for_tenant | domain_not_verified | physical_address_required }

  /v1/messages:
    post:
      tags: [Messages]
      summary: Send a one-off 1:1 message (with attachments)
      description: |
        Scope: `messages:send`. Tenant-authored 1:1 message with inline body +
        attachments, sent synchronously (compose semantics — immediate
        dispatch). Own-domain enforcement: `sender_id` must belong to the
        tenant AND its domain must be verified, else 403 `domain_not_verified`
        — no fallback to a shared/platform domain.

        `message_class: "business"` → operational (1:1 relationship content,
        no unsubscribe footer; marketing opt-outs don't block, hard
        suppressions always do). `message_class: "marketing"` → 422
        `use_campaigns_for_marketing` (marketing mail needs the
        unsubscribe/footer machinery in campaigns/sequences).

        Attachments: ≤10 files, ≤15MB total decoded, extension allowlist
        (pdf, xlsx, xls, csv, png, jpg, jpeg, docx, txt). Rate limit: 120
        messages/hour per tenant on top of the per-key limit; send quota
        gated + metered like other tenant-authored mail.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, sender_id, to, subject, html, message_class]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                sender_id: { type: string, format: uuid }
                to:
                  type: array
                  items:
                    type: object
                    required: [email]
                    properties: { email: { type: string, format: email }, name: { type: string } }
                subject: { type: string }
                html: { type: string }
                text: { type: string }
                reply_to: { type: string, format: email }
                dot_number: { type: integer }
                message_class: { type: string, enum: [business, marketing] }
                attachments:
                  type: array
                  items:
                    type: object
                    properties:
                      filename: { type: string }
                      content_base64: { type: string }
                      content_type: { type: string }
      responses:
        "200": { description: "`{ ok, message_id, provider_message_id }`" }
        "403": { description: domain_not_verified }
        "422": { description: use_campaigns_for_marketing }

  /v1/analytics/overview:
    get:
      tags: [Analytics]
      summary: Tenant performance rollup for a date window
      description: |
        Scope: `analytics:read`. Same rollup as the dashboard Email
        Performance report: totals, daily + day-of-week series, trigger
        breakdown, top messages, suppression breakdown. Default window:
        trailing 30 days. Opens are inflated by Apple MPP — use clicks.
      parameters:
        - $ref: "#/components/parameters/ProductTenantIdQuery"
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
      responses:
        "200": { description: "`{ ok, window, report, notes }`" }

  /v1/analytics/campaigns/{id}:
    get:
      tags: [Analytics]
      summary: Per-campaign engagement rollup (+ A/B variant splits)
      description: "Scope: `analytics:read`. total/delivered/opened(unique)/clicked(unique)/bounced/complained/unsubscribed, per-variant when A/B ran."
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ProductTenantIdQuery"
      responses:
        "200": { description: "`{ ok, totals, variants?, winner?, notes }`" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/platform/health:
    get:
      tags: [System]
      summary: Deep platform diagnostics (Ops Agent contract)
      description: |
        Scope: `platform:health:read` (system-level — issue only to the
        master-o ops integration). Returns every health check with
        observation + likely cause + fix runbook: database, send-worker
        heartbeat, worker failures, queue depth/stuck, failed-send reasons,
        engagement ingestion, outbound webhook deliveries, domain
        verification, config presence (never values), and a live Resend API
        ping. Responds 503 when overall status is critical, so a dumb
        uptime monitor works too. No `product_tenant_id` required.
      responses:
        "200": { description: "`{ ok, overall: ok|warn, generated_at, checks: [{id, area, name, status, observation, cause?, fix?[], link?}] }`" }
        "503": { description: "Same body with `overall: crit` — sending impaired" }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List your outbound webhook subscriptions
      description: "Scope: `webhooks:read`. Also returns the supported event list."
      parameters: [ { $ref: "#/components/parameters/ProductTenantIdQuery" } ]
      responses:
        "200": { description: "`{ ok, subscriptions, supported_events }`" }
    post:
      tags: [Webhooks]
      summary: Subscribe to engine events
      description: |
        Scope: `webhooks:write`. Events: `email.delivered`, `email.opened`,
        `email.clicked`, `email.bounced`, `email.complained`,
        `email.unsubscribed`, `sequence.completed`, `contact.created`.
        Deliveries are POSTs signed Stripe-style —
        `X-Seven16-Signature: t=<unix>,v1=<hmac-sha256(secret, "{t}.{body}")>`
        with `X-Seven16-Event` naming the event. Retries: exponential backoff
        (1m/5m/30m/2h/12h), dead after 5 attempts. **The signing secret is
        returned exactly once at creation.** Max 10 active subscriptions per
        tenant per integration.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id, url, events]
              properties:
                product_tenant_id: { type: string }
                engine_tenant_id: { type: string }
                url: { type: string, description: Must be https:// }
                events: { type: array, items: { type: string } }
                description: { type: string }
      responses:
        "201": { description: "`{ ok, subscription, signing_secret }` — secret shown once" }
        "409": { description: subscription_limit_reached }

  /v1/webhooks/{id}:
    patch:
      tags: [Webhooks]
      summary: Pause/resume or repoint a subscription
      description: "Scope: `webhooks:write`. Status transitions: active ↔ paused."
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id]
              properties:
                product_tenant_id: { type: string }
                url: { type: string }
                events: { type: array, items: { type: string } }
                status: { type: string, enum: [active, paused] }
                description: { type: string }
      responses:
        "200": { description: "`{ ok, subscription }`" }
    delete:
      tags: [Webhooks]
      summary: Disable a subscription (soft delete)
      description: "Scope: `webhooks:write`"
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_tenant_id]
              properties:
                product_tenant_id: { type: string }
      responses:
        "200": { description: "`{ ok, subscription_id, status: disabled }`" }

components:
  parameters:
    ProductTenantIdQuery:
      in: query
      name: product_tenant_id
      required: true
      schema: { type: string }
      description: Your product's tenant id — resolved to the engine tenant via tenant mappings.
    PathId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
  responses:
    BadRequest:
      description: "`{ ok: false, error: <validation code> }`"
    NotFound:
      description: "`{ ok: false, error: <object>_not_found }`"
