Everything you need to put Morvero in your products: install the widget, collect feedback and usage data, and turn it into portfolio decisions.
Morvero is portfolio-level product intelligence for enterprise software: usage analytics, feature-level adoption, in-product feedback, NPS, and AI-drafted backlogs across every internal app and AI-agent pilot — flat-priced per portfolio (never per-user or per-event), anonymous by design, installed with one script tag per product. Feedback flows to Jira or Azure DevOps as tickets with acceptance criteria and cited user evidence.
Reading this as an AI agent? The same content is served as clean Markdown at /docs.md (also /llms-full.txt); the short machine-readable summary is /llms.txt.
Paste the snippet into the product's footer or layout template so it renders on every page:
<script async src="https://YOUR-MORVERO-HOST/widget.js"
data-feedback-key="fbk_your_product_key"></script>
async).data-feedback-ignore attribute on the element or a container.Every page load and SPA route change is recorded as an anonymous pageview. Nothing to do.
#/invoices, #products) rather than the path, add data-feedback-hash-routing to the script tag:
<script async src=".../widget.js" data-feedback-key="fbk_..." data-feedback-hash-routing></script>
The hash then counts as part of the route, so each view is its own page (and function clicks and feedback are attributed to the view they happened on). It's opt-in because on an ordinary site the hash is an anchor link — treating those as routes would split one page into dozens.<button data-feedback-action="export-csv">Export CSV</button>
Every click shows up under Most / least used functions.
MorveroFeedback.track('name') records a function use; MorveroFeedback.open() opens the feedback panel.
<script async src=".../widget.js" data-feedback-key="fbk_..." data-feedback-cohort="finance"></script>
Pass a coarse group label (department, region, role family) and the product page gains a usage-and-sentiment breakdown by cohort. Aggregate-only with a k-anonymity floor: cohorts under 5 visitors in the selected range are withheld entirely, so the label can never single out a person. Never pass usernames or employee ids.
<script> tag — a tag with src ignores its inline content. And because the widget loads async, calls made at page-load time need the queue stub below (calls from click handlers later don't).<script>
window.MorveroFeedback = window.MorveroFeedback || {
q: [],
track: function (n) { this.q.push(['track', n]); },
open: function () { this.q.push(['open']); }
};
MorveroFeedback.track('uptime-page-viewed'); // queued, replayed when the widget loads
</script>
<script async src="https://YOUR-MORVERO-HOST/widget.js" data-feedback-key="fbk_..."></script>
Every pageview and function event is stamped with the visitor's country — nothing to configure and nothing extra sent by the widget. The server resolves the request's IP address to a two-letter country code the moment the event arrives (in-process, using a bundled GeoLite2 country database — no third party sees the IP) and stores only the code; the IP itself is never written anywhere. The Overview shows a world heat map of where your users are, and each product page gets a Geography card breaking down which pages and functions are used from which country. Events collected before this feature existed, or from IPs that can't be resolved (e.g. corporate VPN egress ranges not in the database), appear as Unknown. If you front Morvero with Cloudflare or a similar CDN, its country header is used instead — it's fresher than any bundled database.
The web widget is just a client of a small public REST API — native iOS and Android apps (and server-side integrations) call the same two endpoints directly. No SDK to install, nothing to bundle. The full surface (collection API + Enterprise read API) is machine-readably specified at /openapi.json.
GET /api/widget-config?key=fbk_your_product_key. The response includes a short-lived token (valid ~15 minutes). Fetch it right before submitting; if a queued submission gets a 401 token_invalid, fetch a fresh token and retry.POST /api/collect/feedback with a JSON body.# 1. token
curl "https://YOUR-MORVERO-HOST/api/widget-config?key=fbk_..." \
-H "X-Morvero-App-Id: com.company.app"
# 2. submit
curl -X POST "https://YOUR-MORVERO-HOST/api/collect/feedback" \
-H "Content-Type: application/json" \
-H "X-Morvero-App-Id: com.company.app" \
-d '{
"key": "fbk_...",
"token": "TOKEN_FROM_STEP_1",
"rating": 4,
"comment": "Love the new checkout, but Apple Pay failed once.",
"path": "/checkout",
"title": "Checkout",
"platform": "ios",
"visitorId": "STABLE_ANONYMOUS_UUID"
}'
rating — required integer 1–5. Everything else is optional.path / title — use your screen name (e.g. /checkout) so mobile screens appear in the same page analytics as web routes.platform — "ios", "android", or "api"; defaults to "web". Shows as a badge in triage and is filterable there and in the CSV export.visitorId — generate a random UUID once and store it in the iOS Keychain / Android EncryptedSharedPreferences. Never use a device identifier (IDFA/AAID) — the ID should be anonymous and resettable, which keeps you clean under GDPR/KVKK.screenshot — optional data:image/jpeg;base64,… or PNG data URL, ≤ ~2 MB. Capture only with the user's explicit consent, and redact anything sensitive before encoding.POST /api/collect/nps with {key, token, score: 0–10, comment?, visitorId, path?}.POST /api/collect/pageview {key, path, title?, visitorId} on screen appear, and POST /api/collect/action {key, action, path?, visitorId} on feature use. Neither needs a token.If you'd rather not hand-roll the calls, two single-file, dependency-free wrappers implement the whole flow — anonymous visitor id persistence, the X-Morvero-App-Id header, and the token fetch + retry for feedback. Add the file to your app target and go:
// Swift
MorveroFeedback.configure(origin: "https://YOUR-MORVERO-HOST", key: "fbk_...", appId: "com.company.app")
MorveroFeedback.trackScreen("/invoices")
MorveroFeedback.trackAction("export-csv")
MorveroFeedback.submitFeedback(rating: 4, comment: "Works well")
// Kotlin
MorveroFeedback.configure(context, origin = "https://YOUR-MORVERO-HOST", key = "fbk_...", appId = "com.company.app")
MorveroFeedback.trackScreen("/invoices")
MorveroFeedback.submitFeedback(4, "Works well")
Every call is fire-and-forget on a background thread and never crashes the host app. Both files support the optional cohort label (see Cohorts).
API keys are public by design. On the web you pin them with allowed domains; for native apps set allowed app IDs (product page → Mobile apps & REST API) and send yours in the X-Morvero-App-Id header on every call. The rules:
Like a browser Origin, the header is a declaration, not proof — it stops casual key reuse, not a determined attacker. The per-visitor caps and token expiry below are the real abuse controls.
struct MorveroFeedback {
static let host = "https://YOUR-MORVERO-HOST"
static let key = "fbk_..."
static let appId = "com.company.app"
static func submit(rating: Int, comment: String, screen: String) async throws {
// 1. token
var cfg = URLRequest(url: URL(string: "\(host)/api/widget-config?key=\(key)")!)
cfg.setValue(appId, forHTTPHeaderField: "X-Morvero-App-Id")
let (cfgData, _) = try await URLSession.shared.data(for: cfg)
let token = (try JSONSerialization.jsonObject(with: cfgData) as! [String: Any])["token"] as! String
// 2. submit
var req = URLRequest(url: URL(string: "\(host)/api/collect/feedback")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(appId, forHTTPHeaderField: "X-Morvero-App-Id")
req.httpBody = try JSONSerialization.data(withJSONObject: [
"key": key, "token": token, "rating": rating, "comment": comment,
"path": screen, "platform": "ios", "visitorId": anonymousId(),
])
_ = try await URLSession.shared.data(for: req)
}
}
suspend fun submitFeedback(rating: Int, comment: String, screen: String) {
val host = "https://YOUR-MORVERO-HOST"; val key = "fbk_..."; val appId = "com.company.app"
val http = OkHttpClient()
// 1. token
val cfg = Request.Builder().url("$host/api/widget-config?key=$key")
.header("X-Morvero-App-Id", appId).build()
val token = http.newCall(cfg).execute().use {
JSONObject(it.body!!.string()).getString("token")
}
// 2. submit
val body = JSONObject(mapOf(
"key" to key, "token" to token, "rating" to rating, "comment" to comment,
"path" to screen, "platform" to "android", "visitorId" to anonymousId(),
)).toString().toRequestBody("application/json".toMediaType())
val post = Request.Builder().url("$host/api/collect/feedback")
.header("X-Morvero-App-Id", appId).post(body).build()
http.newCall(post).execute().close()
}
429, so treat any non-201 as "drop or queue, never crash". Queue offline submissions and re-fetch the token when flushing.platform to the OS you detect at runtime.Enterprises pilot dozens of internal AI agents — support copilots, HR bots, SQL assistants — and nobody can say which are used, liked, or worth continuing. Morvero answers that the same way it does for products: each agent pilot is a product with usage, sentiment, outcomes and cost on one scorecard.
| Agent event | Morvero call | Notes |
|---|---|---|
| Conversation started | POST /api/collect/pageview | path = entry surface (/slack, /web-chat, /vscode) |
| Capability / tool call | POST /api/collect/action | action = capability name; optional cost in USD (tokens etc.) |
| Task outcome | POST /api/collect/action | reserved names: task-completed, task-failed, escalated-to-human — these drive the success/escalation KPIs |
| User thumbs 👍/👎 | POST /api/collect/feedback | thumb: "up"|"down" (or rating 1–5), comment, platform: "agent" |
| Periodic CSAT | POST /api/collect/nps | 0–10, same as web NPS |
With the conventions above, each agent product page shows task success rate (completed ÷ decided), escalation rate (escalations ÷ conversations), cost per conversation and cost per successful task; the Overview table ranks every pilot side by side. Fund the leaders, fix the frustrating, retire the unused — with evidence.
All the instrumentation you need is this class (Node 18+, zero dependencies). Framework adapters below just decide when to call it.
class MorveroAgent {
constructor({ host, key, userId }) {
Object.assign(this, { host, key, userId });
}
#post(path, body) { // fire-and-forget: telemetry must never break the agent
fetch(this.host + path, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: this.key, visitorId: this.userId, ...body }),
}).catch(() => {});
}
conversationStarted(surface = '/chat') { this.#post('/api/collect/pageview', { path: surface }); }
capability(name, cost = 0, surface = '/chat') { this.#post('/api/collect/action', { action: name, cost, path: surface }); }
taskCompleted() { this.capability('task-completed'); }
taskFailed() { this.capability('task-failed'); }
escalated() { this.capability('escalated-to-human'); }
async thumbs(direction, comment = '', surface = '/chat') {
const cfg = await (await fetch(`${this.host}/api/widget-config?key=${this.key}`)).json();
this.#post('/api/collect/feedback', { token: cfg.token, thumb: direction, comment, path: surface, platform: 'agent' });
}
}
const morvero = new MorveroAgent({ host: 'https://YOUR-MORVERO-HOST', key: 'fbk_...', userId: 'emp-8f2a' });
Claude Agent SDK / Anthropic tool loop — count a conversation per session, a capability per tool call, and cost from usage:
morvero.conversationStarted('/support-copilot');
for await (const message of agentSession) {
if (message.type === 'tool_use') morvero.capability(message.name);
if (message.usage) morvero.capability('turn', costFromUsage(message.usage)); // your $/token math
}
morvero.taskCompleted(); // or taskFailed() / escalated() per your outcome logic
LangChain (JS) — a callback handler:
const handler = {
handleChainStart: () => morvero.conversationStarted('/langchain-app'),
handleToolStart: (tool) => morvero.capability(tool.name),
handleLLMEnd: (out) => morvero.capability('turn', costFromUsage(out.llmOutput?.tokenUsage)),
};
await chain.invoke(input, { callbacks: [handler] });
OpenAI Agents (Python) — same three calls with requests; fire from your run hooks:
import requests
def emit(path, **body):
try: requests.post(f"{HOST}{path}", json={"key": KEY, "visitorId": user_id, **body}, timeout=2)
except Exception: pass
emit("/api/collect/pageview", path="/hr-bot") # on run start
emit("/api/collect/action", action=tool_name, cost=turn_cost_usd) # on tool call
emit("/api/collect/action", action="task-completed") # on success
Framework APIs move fast — treat these as shapes, not gospel: anywhere you can hook "run started / tool called / run ended", Morvero can score the pilot.
feedback.created, feedback.status_changed, alert.created — to Slack/Teams incoming webhooks, Zapier, or your own service. Verify payloads with the X-Morvero-Signature HMAC header; the signing secret is shown once at creation./api/v1/overview, /api/v1/products, /api/v1/products/:id/analytics, /api/v1/feedback. Aggregates and comments only — never attachments.Morvero ships a remote MCP server, so AI coding agents can read your feedback data and instrument your codebase themselves. Add it to Claude Code:
claude mcp add --transport http morvero https://morvero.com/mcp
On first use the agent opens your browser: sign in to Morvero (passkey or email link), review the consent screen, click Allow. The connector then acts with your role and workspace — OAuth 2.1 with PKCE, tokens stored hashed, revocable by deactivating your account's grants. Works with any MCP client that supports Streamable HTTP + OAuth (Claude Code, Claude.ai connectors, Cursor…). Working examples for every integration surface — including a portable install skill for coding agents — live at github.com/Morvero/examples.
| Tool | What it's for |
|---|---|
list_products / create_product | Find or create the product a codebase belongs to (apps and AI-agent pilots) |
get_install_snippet | Ready-to-paste widget snippet or agent-telemetry calls, with the real API key and install notes |
get_instrumentation_guide | Platform guides: web, iOS, Android, AI-agent conventions |
list_feedback | Real user comments & ratings, filterable — "what are users complaining about?" |
list_backlog / get_backlog_item | AI-suggested backlog with acceptance criteria and the verbatim user evidence — implement what the team accepted |
get_product_analytics | Usage/sentiment totals + agent-pilot KPIs for prioritization |
verify_install | Confirms data is actually arriving after instrumenting — distinguishes working / silent / never-received and names the likely blocker. The agent's definition of done |
data-feedback-action, then calls verify_install after you load the page to confirm events are flowing before it declares the task done.platform: ios, then straight to work.The connector can read feedback and keys and create products, but it can never read attachments beyond what your role allows, touch billing, or manage the team — it holds your role, nothing more.
| Plan | Price | Products | Admins | Max sampling |
|---|---|---|---|---|
| Starter | Free | 2 | 2 | 5% |
| Growth | $99/mo flat | 15 | 10 | 25% |
| Scale | $499/mo flat | 50 | 25 | 50% |
| Enterprise | from $15,000/yr flat | 150–unlimited | Unlimited | 100% |
Always flat per portfolio — never per-user or per-event. Owners move between the self-serve tiers under Settings → Plan & usage; Enterprise is sales-assisted. Limits are enforced by the API, with a clear upgrade message when you hit one. Full comparison: pricing.
fbk_visitor value in their browser's localStorage. When someone sends you that ID, use Visitor data rights to export or permanently erase everything tied to it. Both actions are audit-logged.MorveroFeedback.track() inside the widget's own <script src=…> tag never runs — use a separate tag and the queue stub (section 4).data-feedback-action fires on click — check the attribute is on the element (or an ancestor of) what's actually clicked.[mail] FAILED lines and your Postmark Activity page (right server, outbound stream).