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/breakdownis the only Analytics-Engine-backed route. It answers{ available: false, reason }rather than an error when AE isn't configured, and marks its numbersestimated: truebecause they are sampled.DELETE /admin/members/:idis 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}).