logicspike/docs

Engagement

Engagement — API Reference

Last Updated: 2026-09-05 · Status: Active

Every path here is relative to the gateway: https://api.vlozi.app/engagement.

Route classes and how each authenticates

Class Auth Tenant from
/e/* none — public door the eng_sites row the site_id points at
/admin/* gateway key + JWT/API key + requirePermission x-tenant-id (via requireTenant)
/internal/* x-internal-token (one of three secrets, by path) request body/query
/cron/* gateway key only none — sweeps every tenant
/mcp/* gateway key + requirePermission MCP context
/health, /ready none —

x-tenant-id is ignored on /e/*. That is the single most important line in this document.


Public door

GET /e/embed.js

The vanilla-JS widget. No site lookup, no origin check. Cache-Control: public, max-age=300.

GET /e/:site/config

Public feature flags, so the SDK renders without a second round trip.

{ "commentsMode": "guest", "reactionsEnabled": ["like"], "ratingsEnabled": false,
  "turnstileSiteKey": null, "newsletterFormId": null }

max-age=300. → 200, 404.

POST /e/:site/beacon

View/read/share telemetry. Always answers 204, whatever happens — including an unknown site, a bad origin, a rate limit, a bot, or malformed JSON. A beacon is fire-and-forget; a status code a visitor never sees is only useful to someone probing.

Body arrives as text/plain (that's what navigator.sendBeacon sends, and it keeps the request CORS-simple so there's no preflight). Never route it through zValidator("json").

Field Type
t target key required
e view | read | share required
p string ≤512 path
r string ≤512 referrer
d number 0–1 scroll depth
s sdk | embed

Origin: strict. Limiters: ENG_BEACON_LIMITER (site:ip) then ENG_TARGET_LIMITER (target key), both before the body is parsed. Cap 512 bytes.

GET /e/:site/summary?t=a,b,c

Public counters for up to 50 targets in one request.

{ "items": { "post:abc": {
  "views": 1240, "reads": 480,
  "rating": { "avg": 4.5, "count": 12, "histogram": { "5": 8, "4": 4 } },
  "reactions": { "like": 37 }, "comments": 9, "commentsOpen": true
} } }

views is omitted, not zeroed, when the tenant hides them or the count is below their display floor — a client must not be able to distinguish "hidden" from "zero". Same for rating: null. max-age=30, stale-while-revalidate=120 + ETag.

GET /e/:site/popular?range=7d&limit=10

Top targets by views, with the title/slug snapshot attached so a trending widget needs no second call. max-age=300. Windows clamp to 7/30/90 days.

GET /e/:site/live?t=a,b

Concurrent readers per target, from the counter DO's five-minute window. Answers zeros — never an error — when the DO is unreachable or unbound. max-age=10, because anything longer makes "now" a lie.

POST /e/:site/reactions

Field
t target key required
vid 8–64 chars required — the client's own visitor id
kind 1–30 chars required; must be in reactionsEnabled, or "rating"
value 0–5 required; 0 removes
client_id ≤64 tracing only

→ 200 { data: { kind, value, created } }. Errors: 400 kind_not_enabled, 404 target_not_found, 429, 413, 403 origin_not_allowed.

GET /e/:site/reactions/mine?t=…&vid=…

This visitor's own votes. private, no-store. Missing or invalid params answer 200 {} rather than an error.

GET /e/:site/comments?t=…&limit=50

Published comments, one level of replies, oldest first. max-age=30, stale-while-revalidate=120. Max 200.

POST /e/:site/comments

Field
t target key required
name 1–120 required
email valid email ≤320 required
body 1–5000 required
parentId ≤64 one level only
turnstileToken ≤4000 when the site has a Turnstile key
session ≤4000 member session, when signed in
website ≤1000 the honeypot — forward it verbatim, never filter it out

→ 200 { data: { id, status, editToken? } }.

status is only ever "published" or "pending". Spam and rejected are reported as pending. Do not build anything that expects otherwise; the masking is the point.

editToken is a five-minute capability to fix this one comment, withheld from anything the filter caught.

Errors: 403 comments_disabled, 403 comments_closed, 401 membership_required, 400 captcha_failed, 402 insufficient_credits, 402 plan_limit_reached, 404 target_not_found, 429, 413.

PATCH /e/:site/comments/:id

Body: editToken (16–4000), body (1–5000). The new text is re-screened by the same pipeline, so an edit is not a way past the filters — a live comment that trips a rule comes back as {"status": "pending"}.

403 edit_window_closed covers expired, forged, wrong-comment and wrong-tenant alike.

POST /e/:site/comments/:id/flag

Body optional: reason ≤500, vid 8–64. → 200 { data: { ok: true } }.

POST /e/:site/members/verify-request

Body: email (required), name, returnTo.

Always 200 { data: { ok: true } } — for a new address, an existing member, a throttled one and a banned one alike. Anything else turns this into an oracle for the tenant's commenter list. Limiter: ENG_VERIFY_LIMITER (3/min) plus a 5-minute per-address cooldown.

GET /e/:site/members/verify?token=…&return_to=…

Redeems a magic link. No origin check (a link click has no usable Origin).

Success → 302 to ${return_to}#vlz_session=…. The session goes in the fragment: it never reaches a server, an access log, or a Referer header. Failure → 302 …#vlz_verify=failed. Without a valid return_to, JSON instead — and return_to is only honoured for an origin already on the site's allowlist, or this endpoint would be an open redirect on our own domain.

POST /e/:site/members/me · POST /e/:site/members/logout

Body: session. me answers { data: { member: … | null } } — null for anything invalid, expired or cross-tenant, never an error. logout revokes every session for that member (sessions are stateless, so a single token can't be revoked alone) and always answers 200.


Admin

Every route below requires x-tenant-id and the named permission.

Method Path Permission
GET /admin/sites sites.read
POST · PATCH · DELETE /admin/sites, /admin/sites/:id sites.write
POST /admin/sites/:id/rotate sites.write
GET /admin/settings stats.read
PUT /admin/settings settings.admin
GET /admin/targets stats.read
PATCH /admin/targets/:key settings.admin
GET /admin/analytics/{overview,targets/:key,top,breakdown} stats.read
GET /admin/comments{,/counts,/export,/:id} comments.read
PATCH · POST /admin/comments/:id, /admin/comments/bulk comments.moderate
POST /admin/comments/:id/reply comments.reply
DELETE /admin/comments/:id comments.delete
GET · POST · DELETE /admin/bans{,/:id} bans.manage
GET /admin/members{,/:id} members.read
PATCH · DELETE /admin/members/:id members.manage
GET · POST /admin/webhooks/deliveries{,/:id/retry} settings.admin

Two worth calling out:

  • /admin/analytics/breakdown is the only Analytics-Engine-backed route. It answers { available: false, reason } rather than an error when AE isn't configured, and marks its numbers estimated: true because they are sampled.
  • DELETE /admin/members/:id is GDPR erasure, not deletion. It anonymises in place and answers { data: { anonymised: true, id } } — published comments keep an author so replies aren't orphaned.

Internal

Guarded by x-internal-token, with the expected secret chosen by path prefix.

Path Token Caller
GET /internal/stats ENGAGEMENT_INTERNAL_TOKEN blog's get_analytics
POST /internal/targets/refresh ENGAGEMENT_INTERNAL_TOKEN blog on slug change/unpublish/delete
GET /internal/content-version SITE_SYNC_INTERNAL_TOKEN site-sync's debounce DO
GET·POST·DELETE /internal/platform-blocklist{,/:id} PLATFORM_ADMIN_TOKEN internal-api

Cron

Gateway key only — no tenant, no permission. They sweep every tenant, and billing-crons calls them with x-gateway-key.

POST /cron/flush-accumulator · POST /cron/send-digests · POST /cron/weekly-summary

All three are safe to over-call: the accumulator is idempotent per settlement, and the digest/summary routes bookkeep notify.lastDigestAt / notify.lastWeeklyAt.


MCP tools

17 tools under the engagement namespace. Two cost credits — moderate_comment (metered, 10 comments per credit) and summarize_comments (flat, 5 credits, not charged on an empty thread). Everything else is free.

Seven carry nodeMeta and appear in the Flows palette: set_comments_mode, moderate_comment, reply_to_comment, delete_comment, ban_author, delete_member_data, summarize_comments.

Full descriptors, with each tool's requiredPermission, live in packages/service-catalog/src/engagement.catalog.ts — that file is the source of truth, not this table.

Events

Emitted to vlozi-events; descriptors in packages/service-catalog/src/triggers.ts.

Type Payload Fires
engagement.comment.created targetKey, commentId, status every new comment, held ones included
engagement.comment.published targetKey, commentId once, when it becomes publicly visible
engagement.comment.flagged targetKey, commentId, reason a visitor reports one
engagement.rating.created targetKey, value once per rater — not on a change or removal
engagement.target.milestone targetKey, targetType, targetTitle, metric, threshold a round number is crossed
engagement.member.verified memberId, email, displayName once per reader

Webhooks carry a smaller vocabulary — comment.published, rating.created, target.milestone only — signed sha256=hmac(${timestamp}.${body}).

Engagement