{
  "openapi": "3.1.0",
  "info": {
    "title": "Vidori API",
    "version": "0.1.0",
    "description": "Lead-broker engine for atomic ping-post / RTB lead auctions. Phase 1 wedge: `auto_insurance_us`.\n\n**Auth model** (bearer tokens, per role):\n- **Seller** — `/ping` + `/post`. Keys provisioned out-of-band; SHA-256 of plaintext stored in `vidori/dev/seller-keys` Secrets Manager.\n- **Buyer** — `/bid` + `/buyer/{id}` reads. Plaintext API key returned at buyer registration (one-shot).\n- **Admin** — `/buyer` register. Single admin token in `vidori/dev/admin-token`.\n- **Lead ingest** — `/lead/ingest` shared bearer (Phase 1; per-source granular auth deferred).\n\n**Atomic-post**: seller pings partial lead → auction opens → buyers bid → seller calls `/post` with full lead only on win. Engine never holds full PII for non-winning auctions.\n\n**Webhooks**: buyers receive ping + post deliveries at their registered `webhook_url`. Signed with HMAC-SHA256 over `<x-vidori-timestamp>.<body>` keyed by per-buyer `webhook_signing_secret`. 5-min timestamp skew window.",
    "contact": { "email": "privacy@vidori.net" },
    "license": { "name": "Proprietary" }
  },
  "servers": [
    { "url": "https://api.vidori.net", "description": "Dev (currently the only env)" }
  ],
  "tags": [
    { "name": "service", "description": "Service metadata + health" },
    { "name": "auctions", "description": "Atomic ping-post auction lifecycle" },
    { "name": "bids", "description": "Buyer-side bidding" },
    { "name": "buyers", "description": "Buyer registration + CSV export" },
    { "name": "leads", "description": "Buy-first lead ingest (phase 2 path)" },
    { "name": "internal", "description": "Internal fixtures + webhooks (dev only)" }
  ],
  "paths": {
    "/": {
      "get": {
        "tags": ["service"],
        "summary": "Service banner",
        "description": "Returns service metadata. Content-negotiates: `Accept: text/html` returns a human-friendly page, otherwise JSON. Includes `Link: <openapi-url>; rel=\"service-desc\"` header.",
        "responses": {
          "200": {
            "description": "Service banner",
            "headers": {
              "Link": { "schema": { "type": "string" }, "description": "`<https://docs.vidori.net/openapi.json>; rel=\"service-desc\"; type=\"application/json\"`" },
              "X-Robots-Tag": { "schema": { "type": "string" } }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ServiceBanner" } },
              "text/html": { "schema": { "type": "string" } }
            }
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": ["service"],
        "summary": "Liveness probe",
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } }
          }
        }
      }
    },
    "/.well-known/security.txt": {
      "get": {
        "tags": ["service"],
        "summary": "RFC 9116 security contact",
        "responses": {
          "200": { "description": "Plain-text RFC 9116 file", "content": { "text/plain": { "schema": { "type": "string" } } } }
        }
      }
    },
    "/ping": {
      "post": {
        "tags": ["auctions"],
        "summary": "Open auction (seller, atomic-post)",
        "description": "Seller submits a *partial* lead (no PII). Engine opens an auction; buyers have `open_for_bids_ms` to bid. On expiry: `Posted` (winner) → seller must call `/post` with full lead. `NoBids` / `Expired` → seller keeps the lead, owes nothing.\n\n**Bidding window resolution** (in precedence order):\n1. Explicit `open_for_bids_ms` in the request body (clamped to `[100, 10000]`).\n2. Source-default from the registered seller's source_id (e.g. boberdoo_* = 750ms, leadconduit_* = 1000ms, aged_auto_us = 5000ms).\n3. Anonymous fallback (200ms) when no seller bearer is presented.\n\nThe deadline is *advertised* to buyers, not enforced. Buyers who bid after the deadline can still win if no one beat them, up to the sweeper's per-auction grace cutoff (`window + 30s`).",
        "security": [{ "sellerBearer": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PingRequest" } } }
        },
        "responses": {
          "200": { "description": "Auction opened", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PingResponse" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/post": {
      "post": {
        "tags": ["auctions"],
        "summary": "Commit full lead (seller, on auction win)",
        "description": "Seller-only. Requires the auction to be in `Posted` state (the seller won). Engine persists full PII (CMK-encrypted), forwards to winning buyer's webhook. Single-shot per auction — OCC-guarded; replay returns 409.\n\n**Webhook reliability**: if the buyer webhook fails (timeout, non-2xx, SSRF reject), engine **parks** the delivery into the undelivered-deliveries store. `webhook-retry-runner` (cron 1min) retries with exponential backoff up to 5 attempts (~31min total). Persistent failure marks the delivery dead; CW alarm pages ops. Seller sees 200 on `/post` regardless — the auction commit + lead persistence already succeeded.",
        "security": [{ "sellerBearer": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostRequest" } } }
        },
        "responses": {
          "200": { "description": "Lead committed; buyer webhook fired", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "description": "Auction not found" },
          "409": { "description": "Auction not in Posted state OR already posted (single-shot guard)" },
          "422": { "description": "Seller_id mismatch — only the seller that opened the auction may post" }
        }
      }
    },
    "/bid": {
      "post": {
        "tags": ["bids"],
        "summary": "Place a bid on an open auction (buyer)",
        "description": "Buyer-only. Bearer must match a registered buyer. Idempotency-Key required. Decrements monthly quota on accept; refunded on OCC `ConditionalCheckFailed`. Rate-limited per buyer.",
        "security": [{ "buyerBearer": [] }],
        "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BidRequest" } } }
        },
        "responses": {
          "200": { "description": "Bid recorded" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "description": "Auction closed / already-won-by-other / OCC retry exhausted" },
          "422": { "description": "Bid below floor or amount_cents==0" },
          "429": { "description": "Rate limit or monthly quota exceeded" }
        }
      }
    },
    "/buyer": {
      "post": {
        "tags": ["buyers"],
        "summary": "Register a buyer (admin)",
        "description": "Admin-only. Returns `api_key` (plaintext) + `webhook_signing_secret` ONE TIME ONLY. Operator must capture and hand to buyer out-of-band.",
        "security": [{ "adminBearer": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BuyerRegisterRequest" } } }
        },
        "responses": {
          "201": { "description": "Buyer created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BuyerRegisterResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/buyer/{id}": {
      "get": {
        "tags": ["buyers"],
        "summary": "Get buyer record",
        "security": [{ "buyerBearer": [] }],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "200": { "description": "Buyer profile" },
          "404": { "description": "Not found" }
        }
      }
    },
    "/buyer/{id}/auctions.csv": {
      "get": {
        "tags": ["buyers"],
        "summary": "Buyer auction-activity CSV export",
        "description": "CSV of auctions the buyer participated in. Z6: winner-only fields are redacted on rows where requester did not win.",
        "security": [{ "buyerBearer": [] }],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "200": { "description": "CSV", "content": { "text/csv": { "schema": { "type": "string" } } } }
        }
      }
    },
    "/auction/{id}": {
      "get": {
        "tags": ["auctions"],
        "summary": "Get auction by id",
        "security": [{ "auctionReadBearer": [] }],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "description": "ULID" } }],
        "responses": {
          "200": { "description": "Auction record" },
          "404": { "description": "Not found" }
        }
      }
    },
    "/lead/ingest": {
      "post": {
        "tags": ["leads"],
        "summary": "Buy-first lead intake (phase 2 path)",
        "description": "Phase 2 buy-first path. Source bearer + `?source=<source_id>` query. Engine adapter-normalizes, scores, dedup-checks (cross-supplier 30-day rolling hash), dual-writes raw to S3 + DDB. **Not currently in the live revenue loop**: atomic-post (`/ping` + `/post`) is the active path. `/lead/ingest` exists for aged-leads aggregators and bulk-load testing.",
        "security": [{ "leadIngestBearer": [] }],
        "parameters": [
          { "name": "source", "in": "query", "required": true, "schema": { "type": "string" } },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": { "type": "string" },
            "description": "Optional client idempotency token. When present, retries on transient 5xx replay the cached 202 instead of writing a duplicate raw row."
          }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "type": "object", "description": "Per-source raw shape; adapter dispatches by source_id prefix (boberdoo_*, leadconduit_*, mediaalpha_*)" } } }
        },
        "responses": {
          "202": { "description": "Accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LeadIngestResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "description": "Idempotency-Key in-flight on another worker" }
        }
      }
    },
    "/webhook/mock-buyer": {
      "post": {
        "tags": ["internal"],
        "summary": "Mock-buyer webhook (dev fixture)",
        "description": "Internal mock-buyer Lambda. Receives ping + post webhook deliveries; auto-bids on pings. Not for external use.",
        "responses": {
          "200": { "description": "Acked" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "sellerBearer": { "type": "http", "scheme": "bearer", "description": "Seller plaintext key; SHA-256 hash registered in `vidori/dev/seller-keys`" },
      "buyerBearer": { "type": "http", "scheme": "bearer", "description": "Per-buyer plaintext API key issued on `/buyer` registration" },
      "adminBearer": { "type": "http", "scheme": "bearer", "description": "Admin token (single, in `vidori/dev/admin-token`)" },
      "auctionReadBearer": { "type": "http", "scheme": "bearer", "description": "Shared auction-read key (Phase 1 stop-gap; per-buyer ownership auth pending)" },
      "leadIngestBearer": { "type": "http", "scheme": "bearer", "description": "Shared lead-ingest key (Phase 1 stop-gap; per-source bearer pending)" }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "schema": { "type": "string" },
        "description": "Client-generated idempotency token. Reserve → InFlight | Done | Fresh state machine."
      }
    },
    "responses": {
      "BadRequest": { "description": "Malformed request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Unauthorized": { "description": "Missing / invalid bearer", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
    },
    "schemas": {
      "ServiceBanner": {
        "type": "object",
        "required": ["service", "docs", "openapi", "contact"],
        "properties": {
          "service": { "type": "string", "example": "vidori-api" },
          "docs": { "type": "string", "format": "uri" },
          "openapi": { "type": "string", "format": "uri" },
          "contact": { "type": "string", "format": "email" }
        }
      },
      "Health": {
        "type": "object",
        "required": ["status"],
        "properties": { "status": { "type": "string", "enum": ["ok"] } }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": { "error": { "type": "string" } }
      },
      "Consent": {
        "type": "object",
        "required": ["jurisdiction", "disclosure_text", "ip", "user_agent", "captured_at", "supplier_id"],
        "properties": {
          "jurisdiction": { "type": "string", "enum": ["us", "br"] },
          "disclosure_text": { "type": "string" },
          "ip": { "type": "string" },
          "user_agent": { "type": "string" },
          "captured_at": { "type": "integer", "description": "Unix seconds" },
          "supplier_id": { "type": "string" },
          "trustedform_cert_url": { "type": "string", "format": "uri" }
        }
      },
      "PingRequest": {
        "type": "object",
        "required": ["vertical", "consent"],
        "properties": {
          "vertical": { "type": "string", "example": "auto_insurance_us" },
          "region": { "type": "string", "example": "TX", "description": "Optional. Free-form geo hint forwarded to buyers in the ping payload." },
          "age_band": { "type": "string", "example": "35-44", "description": "Optional. Coarse age bucket; never the raw DOB." },
          "floor_cents": { "type": "integer", "minimum": 0, "description": "Reserve price; bids below are rejected." },
          "lead_id": { "type": "string", "description": "Optional ULID reference into vidori-leads-raw. When present, ping-handler hydrates the auction with the linked raw lead's score (analytics only; Phase 1 not used to gate fan-out)." },
          "open_for_bids_ms": { "type": "integer", "minimum": 100, "maximum": 10000, "description": "Optional per-ping bidding window override. Clamped to [100, 10000]ms. Defaults to the seller's source-config value when omitted." },
          "consent": { "$ref": "#/components/schemas/Consent" }
        }
      },
      "PingResponse": {
        "type": "object",
        "required": ["status", "auction_id", "open_for_bids_ms"],
        "properties": {
          "status": { "type": "string", "enum": ["ok"] },
          "auction_id": { "type": "string", "description": "ULID" },
          "open_for_bids_ms": { "type": "integer", "example": 200 }
        }
      },
      "PostRequest": {
        "type": "object",
        "required": ["auction_id", "lead"],
        "properties": {
          "auction_id": { "type": "string" },
          "lead": {
            "type": "object",
            "description": "Full lead payload, per-source raw shape. Adapter normalizes via the same dispatch as `/lead/ingest`."
          }
        }
      },
      "PostResponse": {
        "type": "object",
        "required": ["status", "lead_id"],
        "properties": {
          "status": { "type": "string", "enum": ["ok"] },
          "lead_id": { "type": "string", "description": "ULID" }
        }
      },
      "BidRequest": {
        "type": "object",
        "required": ["auction_id", "amount_cents"],
        "properties": {
          "auction_id": { "type": "string" },
          "amount_cents": { "type": "integer", "minimum": 1 }
        }
      },
      "BuyerRegisterRequest": {
        "type": "object",
        "required": ["name", "webhook_url", "verticals", "plan"],
        "properties": {
          "name": { "type": "string" },
          "webhook_url": { "type": "string", "format": "uri", "description": "https only. Rejects loopback, RFC1918, link-local (incl 169.254.169.254 IMDS), CGNAT, multicast. Re-resolved + IP-pinned per request via webhook-guard so DNS rebinding can't steer traffic to internal targets after registration." },
          "verticals": { "type": "array", "items": { "type": "string" }, "example": ["auto_insurance_us"] },
          "plan": {
            "type": "string",
            "enum": ["free", "dev", "pro", "enterprise"],
            "description": "Pricing tier. Drives take-rate (Free 1500bps / Dev 1000bps / Pro 500bps / Enterprise 250bps), monthly fee, included-bid quota, and RPS limit."
          }
        }
      },
      "BuyerRegisterResponse": {
        "type": "object",
        "required": ["id", "api_key", "webhook_signing_secret"],
        "properties": {
          "id": { "type": "string" },
          "api_key": { "type": "string", "description": "Shown ONCE; persist on buyer side." },
          "webhook_signing_secret": { "type": "string", "description": "Shown ONCE; HMAC-SHA256 key for webhook signature verification." }
        }
      },
      "LeadIngestResponse": {
        "type": "object",
        "required": ["status", "lead_id", "segment"],
        "properties": {
          "status": { "type": "string", "enum": ["accepted"] },
          "lead_id": { "type": "string" },
          "segment": { "type": "string" }
        }
      }
    }
  }
}
