{
  "openapi": "3.1.0",
  "info": {
    "title": "Soren Free Games API",
    "version": "1.0.0",
    "summary": "Verified free-to-keep PC games, for autonomous agents.",
    "description": "Every PC game currently free to claim and keep permanently, each entry confirmed against the store's own API before publication. Not scraped, not aggregated.\n\nWHY THIS EXISTS: an LLM asked 'what games are free right now?' will invent an answer or repeat a stale post. This feed lets it answer correctly, including the negative case. 'Nothing is free on Steam right now' is a fact a language model cannot safely generate on its own.\n\nPROOF THE VERIFICATION MATTERS: while building this, a widely used free-games aggregator listed a title as an active Steam giveaway. Steam's own API reported the same game at $3.99 with no free-to-keep flag. That entry was discarded here because the store itself disagreed.\n\nBILLING: you are charged when the data CHANGES, never when you ask. Conditional checks are free, so polling every minute and polling once a day cost the same. Spend is bounded by how often games become free (a handful of times a day), not by your request rate \u2014 which makes autonomous spend predictable enough to pre-approve.\n\nLIFECYCLE: see x-lifecycle below for a worked 402 -> pay -> 304 -> 402 example. An agent that mismodels this will over-pay or stall.\n\nSELF-VERIFYING: the `version` field is a SHA-256 over the payload's own content (free_games plus per-store status and count; timestamps excluded). Recompute it to prove the response was not altered in transit. Reference implementation at https://soren.com/verify.js.\n\nSIGNED: every payload carries an Ed25519 signature over its version string (public key at https://soren.com/signing-key.json), so you can prove origin as well as integrity.\n\nCONTRACT: within /v1, fields are never removed or retyped and meanings never silently change. Additions may appear without notice \u2014 ignore unknown fields. Breaking changes ship at /v2 with 180 days' notice. Machine-readable in the `contract` block of every response.",
    "contact": {
      "url": "https://soren.com/contactus"
    }
  },
  "servers": [
    {
      "url": "https://soren.com"
    }
  ],
  "paths": {
    "/v1/free-games": {
      "get": {
        "summary": "Games free to claim right now, across all stores ($0.02 per pull; conditional checks free)",
        "description": "Paid per pull. With a live token and If-None-Match, an unchanged feed returns 304 free; a changed feed returns 402.",
        "parameters": [
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "The version you already hold. Unchanged returns 304 and is not billed."
          },
          {
            "name": "Authorization",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Bearer token from a previous payment, valid 24h."
          },
          {
            "name": "X-Payment",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Payment proof. Presenting a valid proof returns the feed and a fresh 24h token."
          }
        ],
        "responses": {
          "200": {
            "description": "Feed payload plus a fresh 24h access token. ILLUSTRATIVE EXAMPLE - values are representative, not live data. Note gog here shows status 'unavailable' with free_count null: that means we could NOT check, and must never be rendered as 'nothing is free on GOG'.",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                },
                "description": "The feed version. Send it back as If-None-Match."
              },
              "X-Access-Expires": {
                "schema": {
                  "type": "string",
                  "format": "date-time"
                },
                "description": "When the free-check window closes. Renew before this."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Feed"
                },
                "example": {
                  "version": "733d91c2f5353b42a77c23b560b311a1346870a351bc3bb60121abfbb78ab2d2",
                  "updated_at": "2026-07-31T15:17:52.470Z",
                  "free_games": [
                    {
                      "title": "OTXO",
                      "store": "epic",
                      "igdb_id": null,
                      "claim_url": "https://store.epicgames.com/en-US/p/otxo-396b8b",
                      "starts_at": "2026-07-30T15:00:00.000Z",
                      "ends_at": "2026-08-06T15:00:00.000Z",
                      "source": "epic:freeGamesPromotions (discountPercentage === 0, active window)",
                      "first_seen": "2026-07-30T15:04:11.000Z"
                    },
                    {
                      "title": "NIGHTBELL",
                      "store": "itch",
                      "igdb_id": null,
                      "claim_url": "https://example.itch.io/nightbell",
                      "starts_at": null,
                      "ends_at": "2026-08-09T14:57:00.000Z",
                      "source": "itch:on-sale.xml (discountpercent === 100, fullPrice > 0)",
                      "first_seen": "2026-07-30T15:04:11.000Z"
                    }
                  ],
                  "stores": {
                    "steam": {
                      "status": "ok",
                      "free_count": 0,
                      "verified_at": "2026-07-31T15:17:52.470Z",
                      "note": "verified against steam: no free-to-keep games at this time"
                    },
                    "epic": {
                      "status": "ok",
                      "free_count": 1,
                      "verified_at": "2026-07-31T15:17:52.470Z",
                      "note": "verified against epic: 1 free-to-keep game"
                    },
                    "gog": {
                      "status": "unavailable",
                      "free_count": null,
                      "verified_at": "2026-07-31T09:02:11.000Z",
                      "note": "gog could not be reached on this pass - this is NOT a statement that nothing is free there"
                    },
                    "itch": {
                      "status": "ok",
                      "free_count": 1,
                      "verified_at": "2026-07-31T15:17:52.470Z",
                      "note": "verified against itch: 1 free-to-keep game"
                    }
                  },
                  "access": {
                    "token": "sk_live_EXAMPLE_NOT_A_REAL_TOKEN",
                    "expires_at": "2026-08-01T15:17:52.470Z"
                  }
                }
              }
            }
          },
          "304": {
            "description": "Nothing changed since your version. Empty body. NOT BILLED - the common case.",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "402": {
            "description": "Payment required - either you hold no live token, or the feed changed. NOT BILLED. Follows the x402 spec, so `error` is a STRING here, unlike other error responses. Deliberately reveals nothing about feed contents.",
            "content": {
              "application/json": {
                "example": {
                  "error": "payment_required",
                  "message": "The feed has changed. Payment required to pull it.",
                  "x402Version": 2,
                  "accepts": [
                    {
                      "scheme": "exact",
                      "network": "eip155:8453",
                      "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
                      "amount": "20000",
                      "payTo": "0x...",
                      "maxTimeoutSeconds": 120,
                      "extra": {
                        "resource": "/v1/free-games"
                      }
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "description": "Rate limited. Wait Retry-After seconds."
          },
          "503": {
            "description": "Feed unpublished or stale. We fail rather than serve data we cannot vouch for."
          }
        }
      }
    },
    "/v1/upcoming": {
      "get": {
        "summary": "Giveaways announced but not yet started ($0.01 per pull; conditional checks free)",
        "responses": {
          "200": {
            "description": "Announced but not yet started. ILLUSTRATIVE EXAMPLE.",
            "content": {
              "application/json": {
                "example": {
                  "version": "09df59d960066f3cb3afdecbf715d6fa963470af297a9c0e4f27d04cbc426352",
                  "updated_at": "2026-07-31T15:49:09.000Z",
                  "upcoming": [
                    {
                      "title": "Beacon Pines",
                      "store": "epic",
                      "igdb_id": null,
                      "claim_url": "https://store.epicgames.com/en-US/p/beacon-pines",
                      "starts_at": "2026-08-06T15:00:00.000Z",
                      "ends_at": "2026-08-13T15:00:00.000Z",
                      "original_price_cents": 1999,
                      "currency": "USD"
                    }
                  ]
                }
              }
            }
          },
          "402": {
            "description": "Payment required. Not billed."
          }
        }
      }
    },
    "/v1/status": {
      "get": {
        "summary": "Service health. Free. Deliberately excludes how many games are free.",
        "responses": {
          "200": {
            "description": "Health"
          }
        }
      }
    },
    "/mcp": {
      "get": {
        "summary": "MCP server discovery (free) \u2014 returns usage guidance",
        "responses": {
          "200": {
            "description": "How to speak to this MCP server",
            "content": {
              "application/json": {
                "example": {
                  "name": "soren-free-games",
                  "transport": "streamable-http",
                  "usage": "POST JSON-RPC 2.0 to this URL. Try {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}."
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "MCP server (streamable HTTP, JSON-RPC 2.0). Same data and billing as the REST API.",
        "description": "Tools: get_free_games (paid), get_upcoming (paid), check_for_changes (free), get_status (free). Calling a paid tool without payment_proof returns payment instructions rather than an error, so an agent can discover the price and pay unattended. MCP has no conditional-request mechanism, so the REST API's free-304 behaviour is surfaced as the explicit check_for_changes tool.",
        "responses": {
          "200": {
            "description": "JSON-RPC response"
          },
          "202": {
            "description": "Notification accepted, no body"
          },
          "429": {
            "description": "Rate limited"
          }
        }
      }
    },
    "/contactus/send": {
      "post": {
        "summary": "Report an error in the data (free, no authentication)",
        "description": "The reporting path for a wrong entry \u2014 a game that was never actually claimable, a bad end date, a dead claim URL. Usable by agents as well as humans: it is a plain form POST, not a web form that requires a browser. Confirmed errors are published in the corrections list at /v1/status.",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "message"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "Where we can reply."
                  },
                  "message": {
                    "type": "string",
                    "description": "Title, store, what we published, what you observed."
                  },
                  "name": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Received"
          },
          "400": {
            "description": "Invalid email or message"
          }
        }
      }
    },
    "/v1/report": {
      "post": {
        "summary": "Report an error in the data (free, unauthenticated, machine-facing)",
        "description": "Tell us an entry is wrong \u2014 a game that was never claimable, a bad end date, a dead claim URL. Accepts JSON or form encoding. Free and unauthenticated: gating the channel through which someone reports an error would be self-defeating. Reports go to a review channel rather than email, so volume is not a problem \u2014 send one whenever something looks wrong. Confirmed errors appear in the corrections list at /v1/status.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "observed"
                ],
                "properties": {
                  "observed": {
                    "type": "string",
                    "description": "Required. What you found that contradicts our data."
                  },
                  "title": {
                    "type": "string"
                  },
                  "store": {
                    "type": "string",
                    "enum": [
                      "steam",
                      "epic",
                      "gog",
                      "itch"
                    ]
                  },
                  "claim_url": {
                    "type": "string"
                  },
                  "expected": {
                    "type": "string"
                  },
                  "version": {
                    "type": "string",
                    "description": "The feed version you were holding."
                  },
                  "reporter": {
                    "type": "string",
                    "description": "Optional, if we may need detail."
                  }
                }
              },
              "example": {
                "title": "Example Game",
                "store": "steam",
                "observed": "Steam shows this at $9.99; your feed lists it as free to keep.",
                "version": "733d91c2..."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Received"
          },
          "400": {
            "description": "Missing `observed`; the response lists every accepted field"
          },
          "429": {
            "description": "Rate limited"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Feed": {
        "type": "object",
        "required": [
          "version",
          "updated_at",
          "free_games",
          "stores"
        ],
        "properties": {
          "version": {
            "type": "string",
            "description": "SHA-256 of the canonical form {\"offers\":<free_games as published>,\"coverage\":[[store,status,free_count],...] sorted by store}, hex-encoded. Changes only when content changes, never on a mere refresh \u2014 so an unchanged version means nothing to do, and a mismatch means the payload was modified after publication."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "free_games": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Offer"
            }
          },
          "stores": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/StoreStatus"
            }
          },
          "verification": {
            "type": "object",
            "description": "How to recompute `version` yourself. Present so integrity can be checked rather than trusted."
          },
          "signature": {
            "type": [
              "object",
              "null"
            ],
            "description": "Ed25519 signature over the `version` string. Public key at https://soren.com/signing-key.json. Proves the payload originated from Soren; combine with recomputing the hash, which proves the content matches what was signed."
          },
          "contract": {
            "type": "object",
            "description": "Machine-readable statement of what is guaranteed within this major version: fields never removed or retyped, additions possible without notice, meanings never silently changed, canonical hash form fixed, 180 days' deprecation notice. Full text at https://soren.com/contract.md."
          }
        }
      },
      "Offer": {
        "type": "object",
        "required": [
          "title",
          "store",
          "claim_url"
        ],
        "properties": {
          "title": {
            "type": "string"
          },
          "store": {
            "type": "string",
            "enum": [
              "steam",
              "epic",
              "gog",
              "itch"
            ]
          },
          "igdb_id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "claim_url": {
            "type": "string",
            "format": "uri"
          },
          "starts_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null where the store does not publish a start time. Never substituted with our detection time."
          },
          "ends_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "source": {
            "type": "string",
            "description": "The exact endpoint and test that confirmed this entry, e.g. 'epic:freeGamesPromotions (discountPercentage === 0, active window)'. Static per store, so it is included in the version hash."
          },
          "first_seen": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When this offer was first observed. Set once and stable thereafter. There is deliberately no per-offer verified_at: every offer from a store is confirmed in the same pass, so it would duplicate stores[].verified_at exactly, and being a per-run timestamp it would change the version on every pass and bill every conditional poll."
          }
        }
      },
      "StoreStatus": {
        "type": "object",
        "description": "The authoritative per-store answer. free_count null is NOT zero: 'ok' with 0 means we checked and nothing is free; 'unavailable' means we could not check, and must never be rendered as 'nothing is free'.",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "unavailable",
              "not_configured"
            ]
          },
          "free_count": {
            "type": [
              "integer",
              "null"
            ]
          },
          "verified_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "note": {
            "type": "string"
          }
        }
      }
    }
  },
  "x-lifecycle": {
    "summary": "Worked example of the full request lifecycle. Follow this exactly.",
    "steps": [
      {
        "step": 1,
        "request": "GET /v1/free-games",
        "response": "402 Payment Required",
        "body": {
          "error": {
            "code": "payment_required"
          },
          "payment": {
            "amount": "0.02",
            "asset": "USDC",
            "recipient": "0x...",
            "nonce": "..."
          }
        },
        "note": "No credentials. Not billed. The challenge tells you what to pay."
      },
      {
        "step": 2,
        "request": "GET /v1/free-games  with header  X-Payment: <proof>",
        "response": "200 OK",
        "headers": {
          "ETag": "\"733d91c2...\"",
          "X-Access-Expires": "2026-08-01T15:00:00Z"
        },
        "body_excerpt": {
          "version": "733d91c2...",
          "free_games": [
            "..."
          ],
          "stores": {
            "...": "..."
          },
          "access": {
            "token": "sk_live_...",
            "expires_at": "2026-08-01T15:00:00Z"
          }
        },
        "note": "BILLED. Store BOTH the token and the version \u2014 you need both for step 3."
      },
      {
        "step": 3,
        "request": "GET /v1/free-games  with  Authorization: Bearer <token>  and  If-None-Match: \"733d91c2...\"",
        "response": "304 Not Modified",
        "body": null,
        "note": "FREE, and this is the common case. Nothing changed. Keep your cached copy. Repeat as often as you like \u2014 it costs nothing."
      },
      {
        "step": 4,
        "request": "same as step 3, once the feed has changed",
        "response": "402 Payment Required",
        "headers": {
          "ETag": "\"d41d8c...\""
        },
        "note": "FREE. The feed changed. The response deliberately does not say what changed or which store \u2014 pay to see it."
      },
      {
        "step": 5,
        "request": "GET /v1/free-games  with  X-Payment: <new proof>",
        "response": "200 OK",
        "note": "BILLED. Returns the new data and a FRESH 24h token. The cycle returns to step 3."
      }
    ],
    "rules": [
      "Never billed: 304, 402, 429, 4xx, 5xx. Only a 200 carrying data is charged.",
      "Always send If-None-Match. It is free, faster, and the reason your bill stays flat.",
      "The token expires 24h after the last payment. X-Access-Expires is on every response \u2014 renew before it, do not wait for a 402.",
      "If a 200 is lost in transit, retry with the SAME payment proof. Idempotency is keyed on the transaction id, so you will not be charged twice.",
      "On 429, wait exactly Retry-After seconds (an integer, not a date).",
      "free_count: null is NOT zero. status 'ok' with 0 means verified-nothing-free; 'unavailable' means we could not check and you must not report it as nothing free."
    ]
  }
}