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. Includes deletionScheduledAt (ISO-ish string or null) — set when the account has a pending erasure request (E12.5).

POST /api/user/account/delete

Request account deletion (E12.5.1). Body: { confirmEmail }, which must match the signed-in user's email exactly. On success, schedules erasure 14 days out and returns { ok: true, scheduledAt }. Nothing is erased immediately — see src/erasure.ts.

POST /api/user/account/delete/cancel

Cancel a pending deletion request. 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.

POST /api/public/trails/:token/report

E12.6.1/.2 abuse & safety. Report a published (T3) trail or a private (T2) share. Body: { reason: 'spam'|'pii'|'illegal'|'abusive'|'copyright'|'other', note?: string, revealedContent?: string }. No auth required — the reporter is identified by the same anonymous blincr_viewer cookie used for trail-view analytics (t.$token.tsx and p.$token.tsx both set it). Capped at 5 reports/reporter/day (429 beyond that). revealedContent (E12.6.2, T2 only): an optional, reporter-consented digest of what the reporter's browser already decrypted — the only way blincr ever sees T2 content, and only when explicitly included; omit the field entirely to decline (p.$token.tsx's reveal checkbox, unchecked, sends no field at all, not an empty one). Truncated to 20,000 chars server-side.

GET /api/admin/trail-reports

E12.6.1/.2. List open trail reports for moderation — both T3 and T2. Admin only — gated by the ADMIN_EMAILS allowlist (403 otherwise). Each row includes trail_visibility and revealed_content (null for T3, or when a T2 reporter declined to reveal).

POST /api/admin/trail-reports/:id/resolve

E12.6.1/.2. Resolve a report. Body: { action: 'unpublish' | 'revoke' | 'dismiss' }. unpublish (T3) takes the trail private and notifies its owner. revoke (T2) kills the share token and returns visibility to 't1' — blincr never held the content key, so revoking distribution is the whole remedy. Either action auto-closes any other open reports on the same trail/share. Admin only; 409 if already resolved.

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/backup

E12.3.2 T1 encrypted backup. Body: { trailId, folderPath?, envelope: {v, iv, ciphertext} } — all base64, encrypted client-side by the extension (extension/src/crypto.ts). No plaintext ever reaches this handler: the cloud_trails row it creates/updates gets a fixed title: "Encrypted trail" and no metadata_json. Not quota-gated (T1/T2 cost storage only, unlike T3). Requires auth. Returns { ok: true, id } — id is the cloud_trails row id, needed for the backup-fetch and share-private calls below.

GET /api/user/trails/:id/backup

Fetch the ciphertext envelope for restore — owner only. Returns {v, iv, ciphertext} as stored; decrypting it requires the same recovery key that produced it (src/backup.ts getBackup).

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

E12.3.3 T2 private share. Requires the trail to already be t1/t2 (back it up first). Mints a share token, sets visibility = 't2' (is_public stays 0 — never listed/searchable). Returns { shareToken, url } where url is the bare /p/{shareToken} link — the content key is not included; only the caller (the extension, which alone holds the keyring) can compute it and append #k=... itself.

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 marloc-google-webrisk-worker (https://webrisk-gateway.bluemarloc.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; the gateway itself moved off blincr's domain onto its own (webrisk-gateway.bluemarloc.com) 2026-07-13, since a shared gateway shouldn't live on one consumer's brand domain). 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").

Share-creation rate limit (E12.6.2): capped at 20 attempts/user/hour, counted via trail_safety_scans rows (every attempt, successful or blocked, inserts one 'webrisk' row already). 429 { ok: false, error, error_id: "blincr.trail.share_rate_limited" } beyond that.

DELETE /api/user/trails/:id/share

Share-token kill (E12.6.2): revoke a live share without deleting the trail. Clears share_token, sets is_public = 0 — the old link 404s, the owner keeps their content and can re-share (a new token) later. Requires auth. Tier-aware (E12.3.4): a revoked T2 share returns visibility to 't1' (still backed up, just unshared); a revoked T3 share returns it to 't3_self' — never the wrong one.

GET /api/public/trails/:token/ciphertext

E12.3.3 T2 read: the ciphertext envelope for a private share, no auth. Only matches visibility = 't2' rows (distinct from GET /api/public/trails/:token above, which is T3-only and requires is_public = 1). Safe to expose without auth — the fragment key never reaches the server, so the blob is useless without it.

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).