Backend API

All endpoints are served by the Cloudflare Worker at workers/app.ts. CORS headers (Access-Control-Allow-Origin: *) are set on all API responses.

Routing

/api/*        → handleApiRoute()
/session/*    → handleSession() → SessionCoordinator DO
/mcp          → handleMcpRoute() (MCP JSON-RPC for AI agents)
/*            → React Router SSR

Authentication

POST /api/auth/register

Create account. Body: { email, name, password, termsAccepted }. Returns { ok, userId, requiresVerification }.

POST /api/auth/verify

Verify email. Body: { email, code }. Returns { ok, token, user }.

POST /api/auth/resend-code

Resend verification code. Body: { email }.

POST /api/auth/login

Login with password. Body: { email, password }. Returns { ok, token, user } or 403 if email unverified.

POST /api/auth/logout

Invalidate session. Requires Authorization: Bearer <token>.

GET /api/auth/me

Get current user and lazily ensure a public profile exists. Requires auth.

User Profile (authenticated)

GET /api/user/profile

Get the signed-in user's public profile, profile stats, public trail list, and pinned trail IDs. Requires auth.

PUT /api/user/profile

Update profile fields. Body: { username?, displayName?, bio?, coverColor?, isBusiness?, businessVerified?, brandName?, supportUrl?, knowledgeBaseUrl? }. Usernames are normalized to lowercase URL-safe slugs and must be unique. Support and knowledge-base URLs must start with http:// or https://. Requires auth.

POST /api/user/profile/avatar

Upload avatar image as multipart form data field avatar. Stores the object in R2 under avatars/{userId} and updates user_profiles.avatar_key. Requires auth.

PUT /api/user/profile/pins

Set pinned public trails. Body: { trailIds: string[] }, maximum five, all must belong to the signed-in user and already be public. Requires auth.

GET /api/user/webhook

Load the signed-in user's webhook configuration, including target URL, enabled state, and signing secret. Requires auth.

PUT /api/user/webhook

Update webhook configuration. Body: { targetUrl?, enabled?, rotateSecret? }. The target URL must start with http:// or https://. When rotateSecret is true, a new signing secret is generated. Requires auth.

GET /api/user/slack

Load the signed-in user's Slack incoming-webhook configuration. Requires auth.

PUT /api/user/slack

Update Slack sharing configuration. Body: { targetUrl?, enabled? }. The URL must start with http:// or https://. Requires auth.

POST /api/user/follows/:username

Follow the user with the given public username. Requires auth. No body.

DELETE /api/user/follows/:username

Unfollow the user with the given public username. Requires auth.

Public Profile

GET /api/profile/avatar/:userId

Read a public profile avatar from R2. Returns 404 when the user has no avatar.

Public profile pages are server-rendered at /@{username} and include public trail counts, follower/following counts, category filtering, and a follow button when the viewer is signed in. Public profile data also includes the business badge state and optional brand/support links when present.

GET /api/public/profiles/:username

Load the public profile payload for a username, including business metadata, stats, and trail lists. No auth required.

GET /api/public/trails/:token

Load a public trail payload by share token, including the trail summary, author metadata, and embed/public URLs. No auth required.

GET /api/public/integrations/webhooks

Load the webhook event catalog and signature format used by outbound deliveries. Useful for automation platforms such as Zapier and Make. No auth required.

GET /api/public/integrations/notion?token=...

Load the Notion embed helper for a public trail. Returns the canonical /t/{token}/embed URL, a bookmark URL, and a Notion embed block stub. No auth required.

Community

POST /api/social/trails/:token/reactions

Toggle a trail reaction. Body: { kind: "useful" | "interesting" | "saved", pageUrl? }. Requires auth.

DELETE /api/social/trails/:token/reactions

Remove a trail reaction. Body: { kind, pageUrl? }. Requires auth.

GET /api/social/trails/:token/comments

Load reactions and comments for a public trail. Requires auth.

POST /api/social/trails/:token/comments

Add a trail comment. Body: { body, pageUrl? }. Requires auth.

GET /api/notifications

List recent in-app notifications for the current user. Requires auth.

PATCH /api/notifications

Mark one notification or all notifications read. Body: { notificationId? }. Requires auth.

GET /api/notifications/preferences

Load weekly digest and badge settings for the current user. Requires auth.

PATCH /api/notifications/preferences

Update weekly digest and badge settings. Body: { weeklyDigest?, pushEnabled? }. Requires auth.

GET /api/notifications/digest

Load the current user's 7-day notification digest summary. Requires auth.

GET /api/notifications/badge

Load the unread notification count for badge surfaces. Requires auth.

POST /api/live/sessions

Create and start a live co-browsing session for the current user. Body: { ownerName?, version?, installId? }. Requires auth.

GET /api/live/sessions/:id/state

Load the current live session state, including participants and chat history. Requires auth.

GET /api/search?q=...&scope=...

Search public trails and users. scope can be all, trails, users, or public. When signed in, the all scope also includes the user's own cloud trails.

GET /api/public/search?q=...&scope=...

Public, no-auth search over trails and users. Same response shape as /api/search, but never includes the signed-in user's private trail inventory.

Webhooks

Outbound webhook deliveries are signed with X-Blincr-Signature using the same t=<unix>,v1=<hex> format as the AR webhook verifier. Event payloads include event, userId, timestamp, and data. The public integration schema at /api/public/integrations/webhooks describes the available events and payload shapes for Zapier/Make catch-hook recipes. Slack sharing posts a formatted trail preview to the configured Slack incoming webhook when a trail is published via /api/user/trails/:id/share. Discord sharing posts a rich embed to the configured Discord webhook when a trail is published via /api/user/trails/:id/share. Notion can embed the public trail viewer by using the embedUrl returned from /api/public/integrations/notion. The watch worker dispatches blincr.alert (E13.5) when a blincr fires: full alert context plus the anchor trail's trailToken/trailUrl for agent handoff — recipe in docs/trail-bundle.md § "Recipe: alert → agent handoff".

GET /api/collections

List the current user's collections. Requires auth.

POST /api/collections

Create a collection. Body: { name, description?, isPublic? }. Requires auth.

GET /api/collections/:id

Get a collection detail with its trails and collaborators. Private collections are only visible to the owner or members. Requires auth.

DELETE /api/collections/:id

Delete a collection. Requires auth.

POST /api/collections/:id/items

Add a public trail to a collection. Body: { trailToken }. Requires auth.

DELETE /api/collections/:id/items

Remove a trail from a collection. Body: { trailToken }. Requires auth.

POST /api/collections/:id/members

Invite a collaborator to a collection by username. Body: { username, role? }. Requires auth.

Passkeys (WebAuthn)

POST /api/auth/passkey/register-options

Generate registration challenge. Requires auth.

POST /api/auth/passkey/register-verify

Verify and store passkey. Body: { challengeId, response, deviceName? }. Requires auth.

POST /api/auth/passkey/login-options

Generate authentication challenge. No auth required.

POST /api/auth/passkey/login-verify

Verify passkey and return session token. Body: { challengeId, response }.

GET /api/auth/passkey/list

List user's registered passkeys. Requires auth.

DELETE /api/auth/passkey/:id

Remove a passkey. Requires auth.

Trails

GET /api/trails

List all saved trail snapshots from R2. Returns { trails: [{ key, sessionId, size, uploaded }] }.

GET /api/trails/:sessionId

Get full trail JSON from R2.

PATCH /api/trails/:sessionId

Update event label. Body: { eventIndex, note }.

DELETE /api/trails/:sessionId

Delete trail snapshot from R2.

User Trails (authenticated)

Agent trails (E13.4): cloud_trails.is_agent flags trails recorded under the extension's "Record agent session" toggle. Flagged trails are returned to their owner (with is_agent) but are excluded from the feed, explore, suggested profiles, and public search — they never mix into human discovery surfaces.

GET /api/user/trails?folder=/

List user's cloud trails from D1. Requires auth. Each trail row can include an analytics object with creator stats for public trails: views, unique viewers, completion depth, average scroll depth, top pages, likely drop-off page, referrers, countries, and languages.

POST /api/user/trails

Save trail to cloud. Body: { trailId, title, metadata, folderPath? }. Requires auth. Optional category stores the publish category on cloud_trails.

PUT /api/user/trails/:id

Update trail metadata. Body: { title?, folderPath?, isPublic?, category? }. Requires auth.

DELETE /api/user/trails/:id

Delete cloud trail (D1 record + R2 object). Requires auth.

GET /api/user/trails/:id/bundle

Agent-ready export (E13.1): returns a blincr-trail-bundle/1 JSON document — full raw events plus a compact Markdown digest. Only plaintext tiers (t3 / t3_self); encrypted trails answer 403 and export client-side from the extension instead. Schema reference: docs/trail-bundle.md.

POST /api/user/trails/:id/share

Generate share token and mark the trail is_public = 1. Returns { shareToken, url } where url is the canonical public viewer /t/{shareToken} (server-rendered, see frontend docs). Requires auth. Body: { category?, piiAcknowledged? }. When category is provided, it is stored before publishing so the public viewer and profile filters can surface it. piiAcknowledged — see the PII gate below.

Share safety gate (E14.2, Autonomy Ladder L2): before minting the share token, every distinct navigation URL in the trail is checked via src/safe-browsing.ts, which calls gpn-webrisk-worker (https://webrisk.blincr.com) — the fleet's shared capability gateway to Google Web Risk (uris:search: malware, social engineering, unwanted software). blincr holds only a bearer token scoped to its own caller id (WEBRISK_GATEWAY_TOKEN); the gateway holds the Google credential and does its own per-URL caching and quota (originally blincr called Google Safe Browsing directly with a local KV cache — migrated 2026-07-12, E4 in gpn-webrisk-worker's plan). Every scan attempt is logged to trail_safety_scans (see Database Schema), whether the share proceeds or not.

  • 403 { error, error_id: "blincr.trail.share_blocked", flagged: [{url, threatTypes}] } — the trail visits at least one flagged page; remove it and re-share.
  • 503 { error, error_id: "blincr.trail.scan_unavailable" } — the scan itself failed (network/API error). Fails closed: the share does not proceed on a scan failure, it is not treated as clean. Retry shortly.

Publish gate (E12.4): after the (hard-blocking) Web Risk check passes, the trail's E13.1 bundle digest is scanned for PII — emails, phone numbers, IBANs (checksum-validated), card numbers (Luhn-validated), and long token/secret-shaped strings (app/lib/pii-scanner.ts, the same module the dashboard runs client-side before ever sending this request). This is a soft gate: a finding does not permanently block, it requires acknowledgment.

  • 409 { error, error_id: "blincr.trail.pii_detected", matches: [{category, context}] } — send the request again with piiAcknowledged: true in the body to proceed. Every finding is logged to trail_safety_scans regardless of outcome (scan_type: "pii", verdict: "blocked" or "acknowledged").

POST /api/trails/:token/view

Record a public trail view or update its scroll/completion beacon. Body: { visitId?, viewerKey?, completionRatio?, maxScrollDepth? }. Used by the public trail viewer to track privacy-safe creator analytics.

Audit

GET /api/audit?offset=0&limit=50

Paginated audit log entries. Max 100 per request.

GET /api/audit/sessions

Grouped session list with event counts.

DELETE /api/audit/:id

Delete single audit entry.

DELETE /api/audit/session/:sessionId

Delete all audit entries for a session.

Blincrs (standing page watchers)

All routes require an authenticated session: either the blincr_session cookie (dashboard) or an X-Blincr-Ext-Session header carrying the same token value (the extension — see docs/extension.md § Blincrs for why the cookie can't be used directly from a chrome-extension:// origin). Quotas are enforced per plan via entitlements (free: 5 active blincrs).

GET /api/user/blincrs

List the caller's blincrs (status, last_value, next_check_at, recipe).

POST /api/user/blincrs

Create a blincr. Body:

{
  "pageUrl": "https://shop.example/jacket",
  "label": "Jacket price",
  "extractorKind": "css | regex | jsonld",
  "extractorValue": ".price  (selector | regex | dotted JSON-LD path)",
  "comparator": "changed | lt | gt | appeared | disappeared",
  "threshold": 150,            // required for lt/gt
  "intervalBucket": "5m | 30m | 6h | daily",   // default 30m
  "trailToken": "optional — anchor the blincr to a trail"
}

201 with the created blincr, or 4xx with { error, error_id } (blincr.watch.invalid_recipe / blincr.watch.quota_exceeded).

PATCH /api/user/blincrs/:id

Body { "status": "active" | "paused" }. Resuming (active) clears failure state and schedules an immediate check.

DELETE /api/user/blincrs/:id

Delete a blincr (cascades checks + alerts).

GET /api/user/blincr-alerts

Recent alerts across the caller's blincrs (label, page, old/new value, message).

POST /api/run (blincr-watch-worker only)

Manual trigger of a due-check pass — dev/tests use; the cron drives it in prod.

MCP (AI agent access, E13.2)

src/mcp.ts. Makes trails and blincrs readable by MCP clients (Claude, Cowork, any Model Context Protocol client) over Streamable HTTP JSON-RPC. Tools-only capability; no SSE stream, no batch requests.

POST /api/user/mcp-token

Authenticated (cookie or extension header). Mints a 90-day session token for MCP clients — a plain user_sessions row, shown once. Returns { ok, token, expires_at, endpoint }. Revoke by deleting the session (sign-out-everywhere). Minted from the Settings page ("AI agent access" card).

POST /mcp

Auth: Authorization: Bearer <token>getSessionToken falls back to the Bearer credential after the cookie and X-Blincr-Ext-Session header. 401 with WWW-Authenticate: Bearer when missing/invalid.

JSON-RPC methods: initialize (protocol 2025-06-18), ping, tools/list, tools/call; notifications/* are accepted with 202.

Tools:

Tool Behaviour
list_trails Caller's cloud trails: id, title, tier, category, timestamps (max 100).
get_trail Compact digest of one owned trail: pages (≤200), notes/selections (≤20 each), stats, truncated flag.
create_blincr Same validation + plan quota as POST /api/user/blincrs.
list_blincr_alerts Recent alerts, same shape as the REST route.

Tier guard: get_trail serves only t3 / t3_self rows owned by the caller; t1/t2 return an in-band tool error ("client-side export only") — the server never decrypts. Tool results are content: [{type:"text"}] with pretty-printed JSON; domain failures set isError: true.

Sessions (Durable Object)

POST /session/

Create new session. Body: { ownerId, ownerName, version?, installId? }.

GET /session/:id/state

Get session state (participants, trail length, live status, replay room state).

GET /session/:id/trail

Get full trail with events and participants.

POST /session/:id/save

Save trail snapshot to R2.

POST /session/:id/replay/start

Initialize a synchronized replay room for a shared trail. Body: { trailToken, title, ownerId?, isPlaying?, playhead?, speed? }.

GET /session/:id/websocket?participantId=X&name=Y

WebSocket upgrade for real-time co-browsing.

WebSocket messages (client to server):

  • { type: "ping" } — Connection test
  • { type: "navigation", url, payload } — Page navigation
  • { type: "click", payload } — Click event
  • { type: "scroll", payload } — Scroll position
  • { type: "selection", payload } — Text selection
  • { type: "clipboard", payload } — Clipboard event
  • { type: "overlay", payload } — Overlay create/update/delete
  • { type: "chat", body } — Live chat message
  • { type: "control_transfer", targetParticipantId } — Host passes control
  • { type: "page_vote", url, vote } — Page-level group vote during live co-browse
  • { type: "replay_control", playhead?, isPlaying?, speed? } — Watch-party playback control from the host
  • { type: "replay_reaction", emoji } — Emoji reaction during a synchronized replay
  • { type: "voice_join" } — Join the watch-party voice room
  • { type: "voice_leave" } — Leave the watch-party voice room
  • { type: "voice_signal", targetParticipantId, signal } — WebRTC signaling relay for voice chat

WebSocket messages (server to client):

  • { type: "sync", ownerId, trail, currentUrl, participants, chat } — Initial state
  • { type: "sync", ownerId, trail, replay, voice, currentUrl, participants, chat } — Initial state
  • { type: "event", event } — Broadcast event
  • { type: "participant_joined", participant } — New participant
  • { type: "participant_left", participantId } — Participant disconnected
  • { type: "chat", message } — Live chat broadcast
  • { type: "host_changed", ownerId, owner, previousOwner } — Control transfer
  • { type: "replay_sync", replay } — Watch-party playback state update
  • { type: "replay_reaction", reaction } — Emoji reaction broadcast
  • { type: "voice_joined", participant } — Participant joined voice chat
  • { type: "voice_left", participantId } — Participant left voice chat
  • { type: "voice_signal", fromParticipantId, fromParticipantName, signal } — Voice signaling relay
  • { type: "pong", message, timestamp, sessionId } — Ping reply

Health

GET /api/health

Returns { ok, worker, timestamp }.

GET /api/sessions

Recent sessions with event counts (from audit log).