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 withpiiAcknowledged: truein the body to proceed. Every finding is logged totrail_safety_scansregardless 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).