{
  "info": {
    "name": "AI Radar API",
    "description": "Every endpoint the AI Radar API exposes, grouped by how it is authenticated.\n\n## Three separate credentials\n\nThey are deliberately not interchangeable — leaking one must not hand over the others.\n\n| Auth | Used by | How it travels |\n|---|---|---|\n| **Publish key** | An external agent (ChatGPT on a schedule) submitting stories | `Authorization: Bearer {{publish_key}}` |\n| **Admin password** | You, in the operator console | HttpOnly cookie `ai_radar_admin`, set by `POST /admin/login` |\n| **Reader session** | A signed-in reader | HttpOnly cookie `ar_session` on web, or `Authorization: Bearer` on the app |\n\nPostman stores cookies automatically, so once you run **Admin → Login** every other Admin request works. For reader endpoints, run **Accounts → Login (app)** — it captures the token into `{{session_token}}` for you.\n\n## Getting started\n\n1. Import the environment file next to this one.\n2. Set `publish_key` to the value of `PUBLISH_API_KEY` in `apps/api/.env`.\n3. Set `admin_password` to `ADMIN_PASSWORD`.\n4. Run **Health** to confirm the API is up.\n\n## One thing to know about rejections\n\n`POST /publish` answers `200 OK` with `{ \"ok\": false, \"error\": \"duplicate\" }` rather than a 4xx. That is deliberate: a scheduled task treating a duplicate as a transport failure would retry it forever. `ok: false` is a final answer — do not retry it.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },

  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// A fresh version number per run, used in the sample stories'",
          "// model names so each run describes a genuinely different",
          "// release.",
          "//",
          "// It has to be a real version, not just any unique string.",
          "// Duplicate detection compares extracted entities, and an",
          "// unrecognised token extracts no version at all — so the check",
          "// falls through to title similarity, sees ~0.85, and correctly",
          "// calls it the same event. Two real versions separate cleanly.",
          "//",
          "// Set on the first request of a run and reused by the rest, so the",
          "// duplicate check still matches the story submitted immediately",
          "// before it. Cleared after that check, so the next run differs.",
          "if (!pm.collectionVariables.get('run_version')) {",
          "  const n = Math.floor(Date.now() / 1000) % 9000;",
          "  pm.collectionVariables.set('run_version', `${Math.floor(n / 100)}.${n % 100}`);",
          "}"
        ]
      }
    }
  ],

  "variable": [
    { "key": "base_url", "value": "http://localhost:4000/api", "type": "string" },
    { "key": "publish_key", "value": "", "type": "string" },
    { "key": "admin_password", "value": "", "type": "string" },
    { "key": "session_token", "value": "", "type": "string" },
    { "key": "reader_email", "value": "reader@example.com", "type": "string" },
    { "key": "reader_password", "value": "correct horse battery staple", "type": "string" },
    { "key": "locale", "value": "en", "type": "string" },
    { "key": "post_slug", "value": "demo-gemini-3-8-flash", "type": "string" },
    { "key": "post_id", "value": "", "type": "string" },
    { "key": "run_version", "value": "", "type": "string" }
  ],

  "item": [
    {
      "name": "Health",
      "request": {
        "method": "GET",
        "url": { "raw": "{{base_url}}/health", "host": ["{{base_url}}"], "path": ["health"] },
        "description": "No auth. Reports database connectivity, whether AI is enabled, and the monthly budget. Run this first — everything else assumes the API is up."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('API is up', () => pm.response.to.have.status(200));",
              "pm.test('database is reachable', () => {",
              "  pm.expect(pm.response.json().database).to.eql('up');",
              "});"
            ]
          }
        }
      ]
    },

    {
      "name": "Publishing (external agent)",
      "description": "What a ChatGPT scheduled task calls. Bearer key, and it grants submission only — it cannot read the audience, change sources, or trigger pipeline runs.",
      "item": [
        {
          "name": "Get schema",
          "request": {
            "method": "GET",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{publish_key}}", "type": "string" }]
            },
            "url": {
              "raw": "{{base_url}}/publish/schema",
              "host": ["{{base_url}}"],
              "path": ["publish", "schema"]
            },
            "description": "The allowed values for category, audience, worth_trying and status. Defined by the server, so this is always current — call it rather than hard-coding enums into a prompt that goes stale."
          }
        },

        {
          "name": "Submit — held for review",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const body = pm.response.json();",
                  "",
                  "pm.test('accepted', () => {",
                  "  pm.expect(body.ok, body.error || '').to.be.true;",
                  "});",
                  "",
                  "pm.test('held, not live', () => pm.expect(body.status).to.eql('held'));",
                  "",
                  "if (body.id) {",
                  "  pm.collectionVariables.set('post_id', body.id);",
                  "  pm.collectionVariables.set('post_slug', body.slug);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{publish_key}}", "type": "string" }]
            },
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"title\": \"Anthropic ships Claude Opus {{run_version}}\",\n  \"summary\": \"A new frontier model with a larger context window.\",\n  \"what_changed\": \"Context window doubled to 2M tokens.\",\n  \"why_it_matters\": \"Long-document work that needed chunking now fits in one call.\",\n  \"who_should_care\": [\"developers\", \"researchers\"],\n  \"worth_trying\": \"yes\",\n  \"worth_trying_note\": \"Available on the API today.\",\n\n  \"category\": \"model\",\n  \"source_url\": \"https://www.anthropic.com/news/claude-opus-{{run_version}}\",\n  \"source_name\": \"Anthropic\",\n  \"company\": \"anthropic\",\n  \"importance\": 91,\n  \"tags\": [\"claude\", \"context\"],\n\n  \"image_url\": \"https://picsum.photos/id/180/1200/630\",\n  \"image_alt\": \"Claude Opus 5.5 announcement\",\n  \"image_credit\": \"Anthropic\"\n}"
            },
            "url": { "raw": "{{base_url}}/publish", "host": ["{{base_url}}"], "path": ["publish"] },
            "description": "The normal path. With no `status` field it defaults to **held**, which puts the story in the admin queue instead of on the site.\n\nThat default matters: nothing here has read the source, so an invented figure would publish unchecked. The admin queue is the replacement for the verifier the built-in pipeline had, and it is a human one."
          }
        },

        {
          "name": "Submit — publish immediately",
          "request": {
            "method": "POST",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{publish_key}}", "type": "string" }]
            },
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"title\": \"Google opens Gemini {{run_version}} to everyone\",\n  \"summary\": \"General availability for the latest Gemini.\",\n  \"what_changed\": \"The waitlist is removed; the model is available to all API accounts.\",\n  \"why_it_matters\": \"Teams blocked from evaluating it can now do so today.\",\n  \"who_should_care\": [\"developers\"],\n  \"worth_trying\": \"yes\",\n\n  \"category\": \"model\",\n  \"source_url\": \"https://blog.google/gemini-{{run_version}}-ga\",\n  \"company\": \"google\",\n  \"importance\": 85,\n  \"status\": \"published\"\n}"
            },
            "url": { "raw": "{{base_url}}/publish", "host": ["{{base_url}}"], "path": ["publish"] },
            "description": "Goes live immediately **and fires instant alerts** to Telegram, email and push for everyone whose threshold it clears. Use this only once you trust the agent's output.\n\nThe daily cap (`MAX_PUBLISH_PER_DAY`) still applies: past it, a submission is held instead of published rather than being rejected, so nothing is lost."
          }
        },

        {
          "name": "Submit — duplicate (expect ok:false)",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const body = pm.response.json();",
                  "pm.test('200, not an error status', () => pm.response.to.have.status(200));",
                  "pm.test('rejected as duplicate', () => {",
                  "  pm.expect(body.ok).to.be.false;",
                  "  pm.expect(body.error).to.eql('duplicate');",
                  "});",
                  "",
                  "// Last request in this folder: release the version so the",
                  "// next run submits a different release rather than a repeat.",
                  "pm.collectionVariables.unset('run_version');"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{publish_key}}", "type": "string" }]
            },
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"title\": \"Anthropic releases Claude Opus {{run_version}}\",\n  \"summary\": \"Different wording, same event.\",\n  \"why_it_matters\": \"Should be caught by entity matching, not just the URL.\",\n  \"category\": \"model\",\n  \"source_url\": \"https://techcrunch.com/a-different-url-{{run_version}}\"\n}"
            },
            "url": { "raw": "{{base_url}}/publish", "host": ["{{base_url}}"], "path": ["publish"] },
            "description": "Run **Submit — held for review** first, then this.\n\nDifferent title, different URL, same event — caught by entity matching against the last 14 days. A scheduled agent re-posts the same story across runs constantly, so this is the check that stops the site filling with repeats.\n\nNote the `200` with `ok: false`. Retrying this would loop forever."
          }
        },

        {
          "name": "Submit — invalid (expect ok:false)",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const body = pm.response.json();",
                  "pm.test('rejected', () => pm.expect(body.ok).to.be.false);",
                  "pm.test('says which field', () => pm.expect(body.error).to.include('image_alt'));"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{publish_key}}", "type": "string" }]
            },
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"title\": \"An image with no alt text\",\n  \"summary\": \"This is refused.\",\n  \"why_it_matters\": \"An image with no alt text is inaccessible.\",\n  \"category\": \"news\",\n  \"source_url\": \"https://example.com/story\",\n  \"image_url\": \"https://picsum.photos/800/400\"\n}"
            },
            "url": { "raw": "{{base_url}}/publish", "host": ["{{base_url}}"], "path": ["publish"] },
            "description": "Validation says which field is wrong, so the agent can fix it rather than guess.\n\nOther rejections worth knowing: `source_url` is required and must be a real http(s) URL; a `javascript:` or `data:` image URL is refused as an injection vector; an unknown `category` lists the valid ones."
          }
        }
      ]
    },

    {
      "name": "Admin",
      "description": "The operator console. One password from `ADMIN_PASSWORD`, exchanged for an HMAC-signed HttpOnly cookie.\n\n**Run Login first.** Postman keeps the cookie, so everything below then works.",
      "item": [
        {
          "name": "Login",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "// 201, not 200: Nest's default for POST, and this route",
                  "// does not override it.",
                  "pm.test('signed in', () => pm.expect(pm.response.code).to.be.oneOf([200, 201]));",
                  "pm.test('cookie was set', () => {",
                  "  pm.expect(pm.cookies.has('ai_radar_admin')).to.be.true;",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"password\": \"{{admin_password}}\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/admin/login",
              "host": ["{{base_url}}"],
              "path": ["admin", "login"]
            },
            "description": "Sets the `ai_radar_admin` cookie. The password is compared in constant time, so response timing does not leak how much of it was right."
          }
        },
        {
          "name": "Session",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/session",
              "host": ["{{base_url}}"],
              "path": ["admin", "session"]
            },
            "description": "Whether the cookie is still valid. Use this to check you are logged in before blaming an endpoint."
          }
        },
        {
          "name": "Overview",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/overview",
              "host": ["{{base_url}}"],
              "path": ["admin", "overview"]
            },
            "description": "Published, held and queued counts; the last pipeline run; spend against the monthly budget; source health; and today's count against the daily cap."
          }
        },
        {
          "name": "Audience",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/audience",
              "host": ["{{base_url}}"],
              "path": ["admin", "audience"]
            },
            "description": "How many people are reachable across Telegram, email and devices; account health; churn; and what filters people actually choose.\n\nCounts only — never a list of addresses."
          }
        },
        {
          "name": "Posts — published",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/posts?status=published&limit=20",
              "host": ["{{base_url}}"],
              "path": ["admin", "posts"],
              "query": [
                { "key": "status", "value": "published" },
                { "key": "limit", "value": "20" }
              ]
            }
          }
        },
        {
          "name": "Posts — held",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/posts?status=held&limit=20",
              "host": ["{{base_url}}"],
              "path": ["admin", "posts"],
              "query": [
                { "key": "status", "value": "held" },
                { "key": "limit", "value": "20" }
              ]
            },
            "description": "The review queue. Everything submitted with the default `status: held` lands here, alongside drafts the built-in verifier did not trust."
          }
        },
        {
          "name": "Why not published",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/rejected?limit=30",
              "host": ["{{base_url}}"],
              "path": ["admin", "rejected"],
              "query": [{ "key": "limit", "value": "30" }]
            },
            "description": "Every rejection with its reason. The product is what it refuses to publish, so this is the view that tells you whether the filtering is any good."
          }
        },
        {
          "name": "Publish a held post",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/admin/posts/{{post_id}}/publish",
              "host": ["{{base_url}}"],
              "path": ["admin", "posts", "{{post_id}}", "publish"]
            },
            "description": "Approves a held story. `{{post_id}}` is filled in automatically by **Submit — held for review**.\n\nThis fires instant alerts, exactly as a pipeline publish would."
          }
        },
        {
          "name": "Unpublish a post",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/admin/posts/{{post_id}}/unpublish",
              "host": ["{{base_url}}"],
              "path": ["admin", "posts", "{{post_id}}", "unpublish"]
            },
            "description": "Takes a story off the site. Alerts already sent cannot be recalled."
          }
        },
        {
          "name": "Edit a post",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"title\": \"A corrected title\",\n  \"summary\": \"A corrected summary.\",\n  \"what_changed\": \"A corrected what-changed.\",\n  \"why_it_matters\": \"A corrected why-it-matters.\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/admin/posts/{{post_id}}/edit",
              "host": ["{{base_url}}"],
              "path": ["admin", "posts", "{{post_id}}", "edit"]
            },
            "description": "Only these four text fields are editable. Category, importance and tags are set by whoever wrote the story — hand-editing importance would undermine the ranking.\n\nOmitted fields are left alone; sending only `title` changes only the title."
          }
        },
        {
          "name": "Feature a post",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/admin/posts/{{post_id}}/feature",
              "host": ["{{base_url}}"],
              "path": ["admin", "posts", "{{post_id}}", "feature"]
            }
          }
        },
        {
          "name": "Sources",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/sources",
              "host": ["{{base_url}}"],
              "path": ["admin", "sources"]
            },
            "description": "Every feed, with its trust tier, last fetch, and any error."
          }
        },
        {
          "name": "Toggle a source",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/admin/sources/1/toggle",
              "host": ["{{base_url}}"],
              "path": ["admin", "sources", "1", "toggle"]
            },
            "description": "Enable or disable one feed. Replace `1` with a real source id from **Sources**."
          }
        },
        {
          "name": "Runs",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/runs?limit=20",
              "host": ["{{base_url}}"],
              "path": ["admin", "runs"],
              "query": [{ "key": "limit", "value": "20" }]
            }
          }
        },
        {
          "name": "Costs",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/costs",
              "host": ["{{base_url}}"],
              "path": ["admin", "costs"]
            },
            "description": "AI spend by stage and model against the monthly budget. Only covers spend this project makes — an external agent's tokens are on your OpenAI bill and invisible here."
          }
        },
        {
          "name": "Run ingest (free)",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/admin/run/ingest",
              "host": ["{{base_url}}"],
              "path": ["admin", "run", "ingest"]
            },
            "description": "Fetches and prefilters every enabled source. Deterministic code — **costs nothing**. Safe to run any time, and useful for giving an external agent a fresh candidate list."
          }
        },
        {
          "name": "Run editor (SPENDS MONEY)",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/admin/run/edit?limit=20",
              "host": ["{{base_url}}"],
              "path": ["admin", "run", "edit"],
              "query": [{ "key": "limit", "value": "20" }]
            },
            "description": "⚠️ **Makes paid AI calls.** The built-in editor: triage, write, verify, publish.\n\nBounded by `AI_PER_RUN_BUDGET_USD` and `AI_MONTHLY_BUDGET_USD`, both checked before every call. Needs `ANTHROPIC_API_KEY` set.\n\nNot the path in use if an external agent is writing the stories — but it still works, and `npm run dry-run` exercises it for free."
          }
        },
        {
          "name": "Ad slots",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/admin/ad-slots",
              "host": ["{{base_url}}"],
              "path": ["admin", "ad-slots"]
            }
          }
        },
        {
          "name": "Logout",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/admin/logout",
              "host": ["{{base_url}}"],
              "path": ["admin", "logout"]
            }
          }
        }
      ]
    },

    {
      "name": "Public (no auth)",
      "description": "What the website and the app read. No credentials — everything here is public by design, and nothing on the site ever sits behind a login.",
      "item": [
        {
          "name": "Home",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/public/home?locale={{locale}}",
              "host": ["{{base_url}}"],
              "path": ["public", "home"],
              "query": [{ "key": "locale", "value": "{{locale}}" }]
            },
            "description": "Everything the homepage needs in one payload: top stories, hero, per-section lists, and category counts.\n\nSet `locale` to `ar` for Arabic — translated fields fall back to English one field at a time, so a partly-translated story still renders completely."
          }
        },
        {
          "name": "Category",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/public/category/model?locale={{locale}}&limit=30",
              "host": ["{{base_url}}"],
              "path": ["public", "category", "model"],
              "query": [
                { "key": "locale", "value": "{{locale}}" },
                { "key": "limit", "value": "30" }
              ]
            },
            "description": "One of: news, tool, update, model, pricing, research."
          }
        },
        {
          "name": "Post by slug",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/public/post/{{post_slug}}?locale={{locale}}",
              "host": ["{{base_url}}"],
              "path": ["public", "post", "{{post_slug}}"],
              "query": [{ "key": "locale", "value": "{{locale}}" }]
            },
            "description": "The full briefing, related stories, and which locales it exists in. `image` is null when there is none — the design never depends on one."
          }
        },
        {
          "name": "Company",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/public/company/anthropic?locale={{locale}}",
              "host": ["{{base_url}}"],
              "path": ["public", "company", "anthropic"],
              "query": [{ "key": "locale", "value": "{{locale}}" }]
            }
          }
        },
        {
          "name": "Sitemap data",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/public/sitemap",
              "host": ["{{base_url}}"],
              "path": ["public", "sitemap"]
            }
          }
        },
        {
          "name": "Telegram bot username",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/public/telegram-bot",
              "host": ["{{base_url}}"],
              "path": ["public", "telegram-bot"]
            },
            "description": "Returns `{ username: null }` when no bot is configured, and the site then hides the signup entirely rather than rendering a dead link."
          }
        }
      ]
    },

    {
      "name": "Accounts (reader)",
      "description": "Reader accounts. They exist to carry preferences across the web and the app — they never gate content.\n\n**Run Login (app) first**: it captures the Bearer token into `{{session_token}}`, which the requests below use. On the web the same session travels as an HttpOnly cookie instead.",
      "item": [
        {
          "name": "Register",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"{{reader_email}}\",\n  \"password\": \"{{reader_password}}\",\n  \"locale\": \"{{locale}}\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/auth/register",
              "host": ["{{base_url}}"],
              "path": ["auth", "register"]
            },
            "description": "Always answers `{ ok: true }`, whether or not the address was already registered — otherwise the signup form becomes a way to test who has an account. An already-registered address gets an email saying someone tried to sign up with it.\n\nNo session is returned: signing someone in here would reveal which case it was."
          }
        },
        {
          "name": "Login (app — returns a token)",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const body = pm.response.json();",
                  "if (body.ok && body.token) {",
                  "  pm.collectionVariables.set('session_token', body.token);",
                  "  pm.test('token captured', () => pm.expect(body.token).to.be.a('string'));",
                  "} else {",
                  "  pm.test('signed in', () => pm.expect(body.ok, body.error || 'no token').to.be.true);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"{{reader_email}}\",\n  \"password\": \"{{reader_password}}\",\n  \"client\": \"app\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/auth/login",
              "host": ["{{base_url}}"],
              "path": ["auth", "login"]
            },
            "description": "`client: \"app\"` returns the token in the body, because React Native cannot use cookies cleanly. Omit it and you get an HttpOnly cookie instead, with no token in the body.\n\nSame session row either way — only the transport differs, and revoking works identically."
          }
        },
        {
          "name": "Me",
          "request": {
            "method": "GET",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{session_token}}", "type": "string" }]
            },
            "url": { "raw": "{{base_url}}/auth/me", "host": ["{{base_url}}"], "path": ["auth", "me"] },
            "description": "Returns `{ account: null }` rather than 401 when signed out — every page calls this to decide whether to show a sign-in link, and an error for the normal anonymous case would be noise."
          }
        },
        {
          "name": "Preferences — read",
          "request": {
            "method": "GET",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{session_token}}", "type": "string" }]
            },
            "url": {
              "raw": "{{base_url}}/account/preferences",
              "host": ["{{base_url}}"],
              "path": ["account", "preferences"]
            }
          }
        },
        {
          "name": "Preferences — update",
          "request": {
            "method": "POST",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{session_token}}", "type": "string" }]
            },
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"categories\": [\"model\", \"pricing\"],\n  \"companies\": [\"anthropic\"],\n  \"instantThreshold\": 80\n}"
            },
            "url": {
              "raw": "{{base_url}}/account/preferences",
              "host": ["{{base_url}}"],
              "path": ["account", "preferences"]
            },
            "description": "Saving pushes these to every linked channel — email, Telegram and push — so a filter is set once rather than once per channel.\n\nOnly the fields you send are changed. An omitted filter is left alone; an explicitly empty array clears it. Unknown values are dropped rather than stored, because a filter matching nothing looks identical to alerts having broken."
          }
        },
        {
          "name": "Channels",
          "request": {
            "method": "GET",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{session_token}}", "type": "string" }]
            },
            "url": {
              "raw": "{{base_url}}/account/channels",
              "host": ["{{base_url}}"],
              "path": ["account", "channels"]
            }
          }
        },
        {
          "name": "Sessions",
          "request": {
            "method": "GET",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{session_token}}", "type": "string" }]
            },
            "url": {
              "raw": "{{base_url}}/auth/sessions",
              "host": ["{{base_url}}"],
              "path": ["auth", "sessions"]
            },
            "description": "Every active session, with the current one marked. Sessions are real rows precisely so that revoking works — a stateless token could not offer this."
          }
        },
        {
          "name": "Forgot password",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": { "mode": "raw", "raw": "{\n  \"email\": \"{{reader_email}}\"\n}" },
            "url": {
              "raw": "{{base_url}}/auth/forgot",
              "host": ["{{base_url}}"],
              "path": ["auth", "forgot"]
            },
            "description": "Always `{ ok: true }`, registered or not. With no `RESEND_API_KEY` configured the email is rendered into the API log rather than sent — the reset link is visible there."
          }
        },
        {
          "name": "Logout",
          "request": {
            "method": "POST",
            "auth": {
              "type": "bearer",
              "bearer": [{ "key": "token", "value": "{{session_token}}", "type": "string" }]
            },
            "url": {
              "raw": "{{base_url}}/auth/logout",
              "host": ["{{base_url}}"],
              "path": ["auth", "logout"]
            },
            "description": "Revokes the session server-side immediately, rather than waiting for it to expire."
          }
        }
      ]
    },

    {
      "name": "Subscriptions & devices",
      "description": "Signup and push registration. Unauthenticated by necessity — a reader subscribing has no account.",
      "item": [
        {
          "name": "Subscribe by email",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"{{reader_email}}\",\n  \"locale\": \"{{locale}}\",\n  \"wantsInstant\": false\n}"
            },
            "url": {
              "raw": "{{base_url}}/subscribe",
              "host": ["{{base_url}}"],
              "path": ["subscribe"]
            },
            "description": "Double opt-in: the address stays `pending` until the confirmation link is clicked.\n\nThe signup form is currently hidden on the site in favour of Telegram, but the endpoint works."
          }
        },
        {
          "name": "Register a device for push",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"pushToken\": \"ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]\",\n  \"platform\": \"ios\",\n  \"locale\": \"{{locale}}\",\n  \"threshold\": 85,\n  \"categories\": [\"model\"]\n}"
            },
            "url": {
              "raw": "{{base_url}}/devices/register",
              "host": ["{{base_url}}"],
              "path": ["devices", "register"]
            },
            "description": "An upsert: the app registers on every launch, so the same token updates rather than duplicating.\n\nThe token format is validated — a malformed one would be a row that can never receive anything and fails on every send forever."
          }
        },
        {
          "name": "Revoke a device",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"pushToken\": \"ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/devices/revoke",
              "host": ["{{base_url}}"],
              "path": ["devices", "revoke"]
            }
          }
        }
      ]
    }
  ]
}
