Everything speaks JSON over HTTPS at https://sincewhen.dev. Two ways to authenticate: a browser session cookie (the dashboard), or an API key sent as Authorization: Bearer sw_live_… — keys work everywhere and are what agents and scripts should use.
POST /api/auth/signup
Content-Type: application/json
{ "email": "you@example.com", "password": "min 10 chars", "create_api_key": true }
→ 201 { "user": {…}, "api_key": { "key": "sw_live_…" }, "mcp": {…} }
With create_api_key the response carries your key once — store it. Without it you get a session cookie (browser flow).
GET /api/watches list your watches
POST /api/watches { "url": "https://…", "name": "optional",
"css_selector": "table.pricing" }
PATCH /api/watches/:id { "name": …, "webhook_url": … }
DELETE /api/watches/:id
GET /api/watches/:id/changes?since=… change events for one watch
The first check runs within a minute of adding a watch and records the baseline snapshot. Free plan checks daily; Pro checks hourly. When several accounts watch the same URL, it is fetched once and shared — be a good citizen by default.
Most pages carry something that moves on its own — a promo banner, a "related posts" rail, a rotating testimonial. Pass a css_selector and only the matching region is ever read:
POST /api/watches
{ "url": "https://example.com/pricing", "css_selector": "table.pricing" }
Everything outside the selector is not filtered out later — it is never looked at, so it cannot produce a change at all. All matches are included, so .changelog li watching twelve list items is the normal case. The page title is excluded too: a marketing rewrite of <title> should not wake a pricing watch.
Scope travels with the content: a scoped snapshot opens by naming the region it is, and diffs are headed url [selector]. Without that, an agent handed a pricing page trimmed to one table will conclude the page does not mention something it does mention, just outside the selector.
The identity of a watch is the URL and the selector, so the same page with two selectors is two independent watches with separate history — and they still cost the site one fetch between them. If a redesign leaves your selector matching nothing, the watch is reported as a coverage gap rather than going quietly still; silence would look exactly like a page that stopped changing.
GET /api/changes?since=2026-08-01T00:00:00Z all changes, JSON GET /api/changes/:id/diff unified diff, text/plain GET /api/changes/:id/snapshot the page after this change, markdown
GET /feed.md?since=2026-08-01T00:00:00Z Authorization: Bearer sw_live_… → text/markdown: every change on your watchlist since that moment, grouped by page, with summaries and compact diffs
Add &diffs=0 for summaries only.
GET /feed/rss/<your feed token>
Token-in-URL so any feed reader works; find and rotate yours in the dashboard under Account.
Remote MCP over streamable HTTP at https://sincewhen.dev/mcp. For Claude Code:
claude mcp add sincewhen --transport http https://sincewhen.dev/mcp \ --header "Authorization: Bearer sw_live_…"
Or in JSON config:
{
"mcpServers": {
"sincewhen": {
"type": "http",
"url": "https://sincewhen.dev/mcp",
"headers": { "Authorization": "Bearer sw_live_…" }
}
}
}
| Tool | What it does |
|---|---|
| changes_since | Everything that changed since a timestamp, one markdown document. The headline tool. Pass use_cursor: true to resume from your saved read position instead of tracking timestamps yourself. |
| ack_changes | Advance your read position after acting on a batch. Until you call it, the same batch is replayed — so an agent that dies mid-work loses nothing. |
| add_watch | Start watching a URL. Optional css_selector narrows it to one region of the page, so nothing outside that region can produce a change. |
| list_watches | List watches with status and last-changed times. |
| remove_watch | Stop watching. |
| get_diff | Full unified diff for one change. |
| get_current_snapshot | Current normalized markdown of a page — read it without fetching. |
| account_status | Plan, limits, usage. |
An agent wakes with no memory of when it last looked, so guessing a since means either re-reading old changes or silently skipping new ones. Let the server hold the position instead:
changes_since({ use_cursor: true }) → your unread changes, plus a "through" timestamp
(reading does NOT advance the cursor)
ack_changes({ through: "…" }) → advance past what you have acted on
The cursor is per API key, so two agents on one account read independently. It only ever advances to a timestamp actually delivered to you, never to "now" — a change whose check was already in flight can't be skipped.
Watches are shared: if anyone already watches a URL, you inherit its existing history the moment you add it, with no extra fetch. For a URL nobody has watched, we also try to reconstruct its past from the Internet Archive — sampling captures across the last year and diffing them. Those entries are always labelled "from the Internet Archive, not observed by us", because we did not witness them: the capture times were not ours to choose, and two captures months apart can hide several changes in between. Pages that return 403 to our crawler are never backfilled — declining to be crawled is a decision we respect rather than route around.
Generate recovery codes from the dashboard under Account. Ten single-use codes; each resets your password and signs out every session. They are shown once and stored only as hashes, so keep them somewhere safe — they are the way back in if you forget your password.
POST /api/auth/backup-codes generate a set (replaces any previous set)
GET /api/auth/backup-codes how many remain (never the codes themselves)
POST /api/auth/redeem-backup-code { email, code, password } → new password, sessions revoked
Codes are forgiving about how they are typed back: case, spacing and dashes don't matter, and an O read as a zero still works.
PATCH /api/watches/:id { "webhook_url": "https://your-endpoint" }
On each change you receive:
POST { "event": "page.changed", "change_id": …, "url": …,
"css_selector": … or null,
"added_lines": …, "removed_lines": …, "change_ratio": …, "summary": … }
css_selector tells a receiver which view of the page fired — two scoped watches on one URL otherwise deliver payloads that look identical.
GET /api/auth/keys list (prefixes only)
POST /api/auth/keys { "name": "…" } → full key, shown once
DELETE /api/auth/keys/:id revoke
| Free | Pro | |
|---|---|---|
| Watched pages | 5 | 100 |
| Check cadence | daily | hourly |
| Diff history | 30 days | 1 year |
| AI change summaries | — | yes |
| Webhooks | — | yes |
Upgrade from the dashboard — $5/month or $48/year, cancel any time from the billing portal.
Watches must point at public http(s) pages. Literal IPs and internal hostnames are rejected, at every redirect hop as well as at creation. Our crawler is honest about itself — see SinceWhenBot. Signups and logins are rate-limited per address. A difference where the only thing that moved is a timestamp or date is treated as noise and not reported; everything else is, however small.