{
  "openapi": "3.1.0",
  "info": {
    "title": "Isocast API",
    "version": "1.0.0",
    "summary": "Per-signal Polymarket weather-market bucket-transition signals for AI agents.",
    "description": "Agents pay per-signal bundles via x402 and get pushed (Telegram) the instant a city's daily-high temperature crosses into a new Polymarket bucket — market URL, old/new reading, and live odds for every bucket. 37 cities. Free endpoints (cities, sample, pricing meta, terms) need no auth; paid endpoints settle USDC on Base via x402. Every JSON object response carries a legal `disclaimer` field: informational data only, not financial or betting advice.",
    "termsOfService": "https://isocast.dev/terms",
    "contact": { "name": "Isocast", "url": "https://isocast.dev", "email": "api@isocast.dev" },
    "license": { "name": "Proprietary — signals licensed for your own use; resale/redistribution not permitted" }
  },
  "servers": [{ "url": "https://api.isocast.dev", "description": "Isocast public edge" }],
  "externalDocs": { "description": "Agent-facing reference (llms.txt)", "url": "https://api.isocast.dev/llms.txt" },
  "tags": [
    { "name": "free", "description": "No auth, no payment. Every JSON object response carries a `disclaimer`." },
    { "name": "delivery", "description": "Free EIP-712-signed Telegram push registration (no payment)." },
    { "name": "paid", "description": "x402 bundle-prepay + entitlement-gated signal reads (USDC on Base, eip155:8453)." }
  ],
  "paths": {
    "/health": {
      "get": {
        "tags": ["free"],
        "operationId": "getHealth",
        "summary": "Liveness + configured payment mode",
        "responses": {
          "200": {
            "description": "Service is up.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": { "type": "boolean" },
                    "service": { "type": "string", "const": "isocast-api" },
                    "mode": { "type": "string", "enum": ["live", "mock"], "description": "x402 payment plane mode" },
                    "ofac": { "type": "object", "description": "Sanctions-screening status (list loaded / extra configured)" },
                    "ts": { "type": "string", "format": "date-time" },
                    "disclaimer": { "$ref": "#/components/schemas/Disclaimer" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/cities": {
      "get": {
        "tags": ["free"],
        "operationId": "listCities",
        "summary": "List all active cities (30s server cache)",
        "responses": {
          "200": {
            "description": "Active cities.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "cities": { "type": "array", "items": { "$ref": "#/components/schemas/City" } },
                    "count": { "type": "integer" },
                    "disclaimer": { "$ref": "#/components/schemas/Disclaimer" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/cities/{slug}": {
      "get": {
        "tags": ["free"],
        "operationId": "getCity",
        "summary": "One city + today's target market day and Polymarket event URL",
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "City slug, e.g. `istanbul`, `nyc`, `los-angeles`." }
        ],
        "responses": {
          "200": {
            "description": "City detail.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "city": { "$ref": "#/components/schemas/City" },
                    "targetDate": { "type": "string", "description": "City-local market day, YYYY-MM-DD." },
                    "marketUrl": { "type": "string", "format": "uri" },
                    "disclaimer": { "$ref": "#/components/schemas/Disclaimer" }
                  }
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/UnknownCity" }
        }
      }
    },
    "/v1/sample": {
      "get": {
        "tags": ["free"],
        "operationId": "getSample",
        "summary": "Promo sample signal (fixed seq-1, never the latest)",
        "description": "Returns the city's seq-1 signal as a shape demo, or a hardcoded synthetic Toronto example (with `synthetic: true`) when no real seq-1 exists. Marked `sample: true`. Never reveals the latest signal.",
        "parameters": [
          { "name": "city", "in": "query", "required": false, "schema": { "type": "string" }, "description": "City slug. Omitted or unknown → synthetic example." }
        ],
        "responses": {
          "200": {
            "description": "A sample signal.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/IsocastSignalPublic" },
                    { "type": "object", "properties": { "sample": { "type": "boolean", "const": true }, "synthetic": { "type": "boolean" } } }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/v1/signals/meta": {
      "get": {
        "tags": ["free"],
        "operationId": "getSignalsMeta",
        "summary": "Pricing + payment metadata (never returns signal rows)",
        "parameters": [
          { "name": "city", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional city slug — echoes `city` + `latestSeq` alongside pricing." }
        ],
        "responses": {
          "200": {
            "description": "Pricing metadata.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignalsMeta" } } }
          },
          "404": { "$ref": "#/components/responses/UnknownCity" }
        }
      }
    },
    "/terms.txt": {
      "get": {
        "tags": ["free"],
        "operationId": "getTermsTxt",
        "summary": "Terms of Service (plain text)",
        "responses": {
          "200": {
            "description": "Terms as text/plain. Sets `X-Terms-URL` header.",
            "content": { "text/plain": { "schema": { "type": "string" } } }
          }
        }
      }
    },
    "/terms.json": {
      "get": {
        "tags": ["free"],
        "operationId": "getTermsJson",
        "summary": "Terms of Service (structured)",
        "responses": {
          "200": {
            "description": "Terms as JSON. Sets `X-Terms-URL` header.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "version": { "type": "string" },
                    "effectiveDate": { "type": "string" },
                    "url": { "type": "string", "format": "uri" },
                    "summary": { "type": "array", "items": { "type": "string" } },
                    "disclaimer": { "$ref": "#/components/schemas/Disclaimer" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/delivery/telegram": {
      "post": {
        "tags": ["delivery"],
        "operationId": "registerTelegramDelivery",
        "summary": "Register/change/delete the Telegram chat that receives your entitled signals (FREE, wallet-signed)",
        "description": "Authorised by an EIP-712 signature that must recover to the claimed `wallet`. No payment; never touches entitlement. On `set`, a Telegram test message is sent BEFORE persisting — an unreachable chat returns 502. An empty `citySlug` means all cities (stored as null).",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TelegramDeliveryRequest" } } }
        },
        "responses": {
          "200": {
            "description": "Delivery configured / deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": { "type": "boolean", "const": true },
                    "wallet": { "type": "string" },
                    "chatId": { "type": "string" },
                    "citySlug": { "type": ["string", "null"] },
                    "action": { "type": "string", "enum": ["set", "delete"] },
                    "disclaimer": { "$ref": "#/components/schemas/Disclaimer" }
                  }
                }
              }
            }
          },
          "400": { "description": "Shape validation failed (invalid wallet, bad action, missing chatId/signature/timestamp)." },
          "401": { "description": "Stale timestamp (>600s skew) or signature verification failed." },
          "404": { "description": "Unknown city, or nothing to delete." },
          "502": { "description": "Telegram test message failed (chat unreachable / bot not configured)." }
        }
      }
    },
    "/v1/subscribe": {
      "post": {
        "tags": ["paid"],
        "operationId": "subscribe",
        "summary": "Buy the next N signals for a city (x402 bundle prepay)",
        "description": "Without a payment credential returns a 402 challenge (see PaymentRequired). With a valid x402 payment: verify → OFAC screen → nonce-binding check → settle → record payment → apply entitlement atomically. The authorization `nonce` MUST bind (payer, citySlug, count) using the salt served in the 402 `extra.binding`; a mismatch is refused before settlement. Top-up semantics: paidThroughSeq becomes max(current, latestSeq) + count. No expiry. Idempotent per settlement txHash.",
        "parameters": [
          { "name": "city", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "count", "in": "query", "required": true, "schema": { "type": "integer", "minimum": 2 }, "description": "Number of signals to buy (integer ≥ 2, and total ≥ 0.01 USDC min spend)." }
        ],
        "security": [{ "x402": [] }],
        "responses": {
          "200": {
            "description": "Purchase applied (or idempotent replay).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubscribeResult" } } }
          },
          "400": { "description": "count < 2 / non-integer, or below the 0.01 USDC minimum spend.", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { "type": "string" }, "minSpendUsd": { "type": "number" } } } } } },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "description": "Payer wallet is on a sanctions list; payment refused (never settled)." },
          "404": { "$ref": "#/components/responses/UnknownCity" },
          "502": { "description": "Charged but entitlement update failed → refund queued. Body `{ error, refund: 'queued' }`." },
          "503": { "description": "Sanctions screening unavailable — fail-closed, nothing settled." }
        }
      }
    },
    "/v1/signals": {
      "get": {
        "tags": ["paid"],
        "operationId": "getSignals",
        "summary": "Read the signals you are entitled to (Bearer receipt or x402 payment)",
        "description": "Authenticate with a Bearer receipt token from a prior /v1/subscribe, OR present an x402 payment (runs the full subscribe flow, then reads). Returns signals with since < seq ≤ paidThroughSeq, ascending, capped at 100/page. Never returns seq > paidThroughSeq. Returns 402 on exhaustion (since ≥ paidThroughSeq), with `paid_through` and `latest` echoed so you know how many more to buy.",
        "parameters": [
          { "name": "city", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "since", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 }, "description": "Return signals with seq strictly greater than this (cursor)." },
          { "name": "count", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 2, "default": 20 }, "description": "Bundle size proposed in the 402 challenge if payment is required." }
        ],
        "security": [{ "receiptToken": [] }, { "x402": [] }],
        "responses": {
          "200": {
            "description": "Entitled signals page.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "signals": { "type": "array", "items": { "$ref": "#/components/schemas/IsocastSignalPublic" } },
                    "next_cursor": { "type": "integer", "description": "Pass as `since` to page forward." },
                    "paid_through": { "type": "integer" },
                    "latest": { "type": "integer" },
                    "more": { "type": "boolean" },
                    "disclaimer": { "$ref": "#/components/schemas/Disclaimer" }
                  }
                }
              }
            }
          },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "description": "Payer wallet sanctioned; payment refused." },
          "404": { "$ref": "#/components/responses/UnknownCity" }
        }
      }
    }
  },
  "components": {
    "responses": {
      "UnknownCity": {
        "description": "Unknown or inactive city.",
        "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { "type": "string", "const": "unknown city" }, "disclaimer": { "$ref": "#/components/schemas/Disclaimer" } } } } }
      },
      "PaymentRequired": {
        "description": "x402 payment challenge. Construct the EIP-3009 authorization nonce from `extra.binding` before paying.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaymentRequired" } } }
      }
    },
    "securitySchemes": {
      "x402": {
        "type": "http",
        "scheme": "x402",
        "description": "Custom HTTP 402 payment flow. The server answers unpaid paid-route requests with a 402 whose body is a PaymentRequired envelope (x402Version + accepts[] + extra.binding + terms). Construct an EIP-3009 `exact`-scheme USDC authorization on Base (network eip155:8453) whose nonce binds (payer, citySlug, count) using the served salt, then retry with the `X-Payment` header. Settlement is settle-early: verify → OFAC screen → bind → settle → entitle."
      },
      "receiptToken": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "isocast-receipt",
        "description": "Opaque receipt token returned by POST /v1/subscribe. Present as `Authorization: Bearer <token>` to read /v1/signals for the paying wallet without re-paying."
      }
    },
    "schemas": {
      "Disclaimer": {
        "type": "string",
        "description": "Legal disclaimer stamped onto every JSON object response.",
        "example": "Informational data only; not financial or betting advice. No guarantee of accuracy or outcome. User assumes all risk. Prediction markets restricted in some jurisdictions. See https://isocast.dev/terms"
      },
      "City": {
        "type": "object",
        "description": "Public-safe city record.",
        "properties": {
          "slug": { "type": "string", "example": "istanbul" },
          "displayName": { "type": "string", "example": "Istanbul" },
          "country": { "type": ["string", "null"] },
          "unit": { "type": "string", "description": "Temperature unit, e.g. `C` or `F`.", "example": "C" },
          "bucketWidth": { "type": "number", "description": "Polymarket bucket width in the city's unit (e.g. 1°C).", "example": 1 },
          "timezone": { "type": "string", "description": "IANA timezone for the city's market day.", "example": "Europe/Istanbul" },
          "resolutionSource": { "type": "string", "description": "Weather resolution source.", "example": "wu" },
          "station": { "type": ["string", "null"] },
          "latestSeq": { "type": "integer", "description": "Highest published signal seq for this city." }
        }
      },
      "IsocastSignalPublic": {
        "type": "object",
        "description": "A bucket-transition signal: a city's daily-high crossed from `old.bucket` into `new.bucket`.",
        "properties": {
          "city": { "type": "string", "example": "istanbul" },
          "displayName": { "type": "string", "example": "Istanbul" },
          "seq": { "type": "integer", "description": "Monotonic per-city sequence (1-based).", "example": 1 },
          "targetDate": { "type": "string", "description": "City-local market day, YYYY-MM-DD.", "example": "2026-07-03" },
          "marketUrl": { "type": "string", "format": "uri", "example": "https://polymarket.com/event/highest-temperature-in-istanbul-on-july-3-2026" },
          "conditionId": { "type": "string", "description": "Polymarket event/condition id (0x…)." },
          "old": { "$ref": "#/components/schemas/Reading" },
          "new": { "$ref": "#/components/schemas/Reading" },
          "buckets": {
            "type": ["array", "null"],
            "description": "Live odds for every bucket at bucketsAsOf.",
            "items": { "$ref": "#/components/schemas/Bucket" }
          },
          "bucketsAsOf": { "type": ["string", "null"], "format": "date-time", "description": "When bucket odds were sampled (freshness ≤ ~10s of the signal)." },
          "source": { "type": "string", "description": "Observation source, e.g. `wu`.", "example": "wu" },
          "observedAt": { "type": "string", "format": "date-time" }
        }
      },
      "Reading": {
        "type": "object",
        "properties": {
          "reading": { "type": "number", "description": "Daily-high temperature reading.", "example": 30.1 },
          "unit": { "type": "string", "example": "C" },
          "bucket": { "type": ["string", "null"], "description": "Polymarket bucket label.", "example": "29-30°C" }
        }
      },
      "Bucket": {
        "type": "object",
        "properties": {
          "label": { "type": "string", "example": "29-30°C" },
          "conditionId": { "type": "string" },
          "yes": { "type": "number", "description": "YES price (0–1).", "example": 0.55 },
          "no": { "type": "number", "description": "NO price (0–1).", "example": 0.45 },
          "midpoint": { "type": "number", "description": "Order-book midpoint (0–1).", "example": 0.55 },
          "dead": { "type": "boolean", "description": "Present+true when the bucket is no longer reachable given the current reading." }
        }
      },
      "SignalsMeta": {
        "type": "object",
        "properties": {
          "city": { "type": "string", "description": "Only present when `?city` was supplied." },
          "latestSeq": { "type": "integer", "description": "Only present when `?city` was supplied." },
          "unitPriceUsd": { "type": "number", "example": 0.005 },
          "minSpendUsd": { "type": "number", "example": 0.01 },
          "currency": { "type": "string", "const": "USDC" },
          "network": { "type": "string", "const": "eip155:8453" },
          "payTo": { "type": ["string", "null"], "description": "Settlement wallet (or null if unset)." },
          "tiers": { "type": "array", "items": { "$ref": "#/components/schemas/Tier" } },
          "disclaimer": { "$ref": "#/components/schemas/Disclaimer" }
        }
      },
      "Tier": {
        "type": "object",
        "properties": {
          "name": { "type": "string", "example": "Standard" },
          "minCount": { "type": "integer", "example": 100 },
          "unitPriceUsd": { "type": "number", "example": 0.0045 },
          "totalUsd": { "type": "number", "example": 0.45 }
        }
      },
      "TelegramDeliveryRequest": {
        "type": "object",
        "required": ["wallet", "action", "signature", "timestamp"],
        "description": "EIP-712 typed-data. Domain: {name:'Isocast', version:'1', chainId:8453}. primaryType: TelegramDelivery. Types.TelegramDelivery = [{wallet:address},{chatId:string},{citySlug:string},{action:string},{timestamp:uint256}]. The signature must recover to `wallet`.",
        "properties": {
          "wallet": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" },
          "chatId": { "type": "string", "description": "Telegram chat id. Required when action='set'." },
          "citySlug": { "type": "string", "description": "City slug, or empty string for all cities." },
          "action": { "type": "string", "enum": ["set", "delete"] },
          "signature": { "type": "string", "description": "EIP-712 signature (0x…)." },
          "timestamp": { "type": "integer", "description": "Unix seconds; must be within 600s of server time." }
        }
      },
      "SubscribeResult": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean", "const": true },
          "city": { "type": "string" },
          "count": { "type": "integer" },
          "unitPriceUsd": { "type": "number" },
          "amountUsd": { "type": "number" },
          "latestSeq": { "type": "integer" },
          "paidThroughSeq": { "type": "integer" },
          "receiptToken": { "type": "string", "description": "Bearer token for reading /v1/signals without re-paying." },
          "txHash": { "type": ["string", "null"] },
          "idempotent": { "type": "boolean", "description": "true when this settlement was already applied." },
          "disclaimer": { "$ref": "#/components/schemas/Disclaimer" }
        }
      },
      "PaymentRequired": {
        "type": "object",
        "description": "x402 PaymentRequired envelope.",
        "properties": {
          "x402Version": { "type": "integer", "const": 1 },
          "accepts": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "scheme": { "type": "string", "const": "exact" },
                "network": { "type": "string", "const": "eip155:8453" },
                "price": { "type": "string", "description": "USD price string, e.g. `$0.1`.", "example": "$0.1" },
                "payTo": { "type": "string", "description": "Settlement wallet address." },
                "description": { "type": "string" },
                "mimeType": { "type": "string", "const": "application/json" }
              }
            }
          },
          "extra": {
            "type": "object",
            "properties": {
              "binding": {
                "type": "object",
                "description": "Anti-grief nonce binding. Compute the EIP-3009 authorization nonce = keccak256(abi.encodePacked(payer, citySlug, uint256 count, bytes32 salt)) using the served salt.",
                "properties": {
                  "formula": { "type": "string", "const": "keccak256(abi.encodePacked(payer, citySlug, uint256 count, bytes32 salt))" },
                  "citySlug": { "type": "string" },
                  "count": { "type": "integer" },
                  "salt": { "type": "string", "description": "bytes32 salt (0x…) — required to compute the nonce; not derivable client-side." }
                }
              }
            }
          },
          "terms": { "type": "string", "format": "uri", "const": "https://isocast.dev/terms" },
          "paid_through": { "type": "integer", "description": "Only on /v1/signals exhaustion — your current entitlement ceiling." },
          "latest": { "type": "integer", "description": "Only on /v1/signals exhaustion — the city's latest seq." },
          "disclaimer": { "$ref": "#/components/schemas/Disclaimer" }
        }
      }
    }
  }
}
