Developer Docs

ReportRoom API & MCP

The publishing layer AI agents call directly: one call turns a deck or report into a beautiful, tracked live URL β€” and reports back who viewed it.

REST API reference REST

Base URL: https://api.reportroom.io Β· Versioned under /v1.

Quickstart

# 1. get an API key (shown once)
curl -sX POST https://api.reportroom.io/v1/signup \
  -H 'content-type: application/json' -d '{"email":"you@example.com"}'

# 2. verify your email β€” click the link we send you. Publishing is blocked until you do.

# 3. publish a markdown deck
curl -sX POST https://api.reportroom.io/v1/documents \
  -H "authorization: Bearer rr_live_..." -H 'content-type: application/json' \
  -d '{"content":"# Hello\n\nMy first **deck**.","content_format":"markdown","type":"deck","slug":"hello"}'

Authentication

Most endpoints require an API key as a bearer token:

Authorization: Bearer rr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Get one from POST /v1/signup (or the MCP create_account tool). Keys are shown once β€” store them securely. Keys look like rr_live_ + 32 hex.

Verify your email before publishing. A new account receives a key immediately but is unverified; publishing stays blocked (403 email_unverified) until you click the link we email you. See URL model & trust tiers.

Errors are JSON: { "error": { "code": "STRING_CODE", "message": "what to do" } }. The HTTP status carries the class; the code string is the stable machine-readable label (match on the literal string β€” casing is inconsistent by design, e.g. cap_reached vs PLAN_REQUIRED). MCP tools return the same code strings inside structuredContent with isError: true. Common codes by area:

URL model & trust tiers

Every account gets a handle β€” a subdomain, auto-generated at signup (an opaque token like u7k2m9qp) and renameable to something friendlier like acme (see POST /v1/handle). Handles are lowercase letters/numbers/hyphens (no leading/trailing or doubled hyphens), 2–32 chars. A slug is unique per account (not global), 3–63 chars [a-z0-9-]; publishing the same slug again updates in place. Verified accounts' documents live at https://<handle>.reportroom.io/<slug>.

Email verification is required to publish. A fresh account is unverified: it gets an API key, but POST /v1/documents (and the MCP publish tool) return 403 email_unverified until you click the verification link we email you. GET /v1/verify?token=… flips the account to verified and unlocks publishing. While unverified, GET /v1/handle reports a preview base on rrpreview.com (noindex) β€” this is the base your links will use once verified; you can't publish to it beforehand. Verification links are valid 24h; request a fresh one from your dashboard if it lapses.

Plans & limits

Limits are per workspace (org). Hitting a cap returns 409 cap_reached (documents/images) or 403 PLAN_REQUIRED/plan_required/PAYMENT_REQUIRED (gated features). Pricing lives at reportroom.io; the API only enforces the limits below.

Plan Live documents Images team visibility Custom domains Data rooms
Free 10 10 β€” β€” β€”
Pro 100 100 β€” β€” β€”
Team unlimited 500 βœ“ βœ“ β€”
Business unlimited 1000 βœ“ βœ“ βœ“

New accounts get 100 trial credits (used by credit-metered actions like PDF export, 5 credits each). Team is billed per seat; Business bundles 3 seats.


POST /v1/signup

Create a free account and receive an API key (shown once). Rate-limited per IP.

Request:  { "email": "you@example.com", "name": "Optional" }
Response: { "data": { "user_id": "usr_…", "org_id": "org_…",
                      "api_key": "rr_live_…", "tier": "unverified", "message": "…" } }

A verification email is sent to the address. The account starts unverified β€” you must verify before publishing (see above). Rate-limited to 5/hour per IP.

GET /v1/verify?token=…

Consumes the emailed verification token and upgrades the account to verified, which unlocks publishing. Any documents already on the preview domain migrate to <handle>.reportroom.io (old preview links 301-redirect); in the normal verify-then-publish flow you have none yet, so migrated is empty. Rate-limited 30/hour per IP. Returns 400 INVALID_TOKEN if the token is missing, invalid, or expired. The link opens a human-friendly HTML page in a browser (Accept: text/html) and returns JSON otherwise.

Response: { "data": { "tier": "verified", "migrated": [], "message": "…" } }

POST /v1/documents

Publish or update a document. Idempotent on slug β€” reuse a slug to update in place. Requires auth. Provide either html (Mode A) or content + content_format + type (Mode B).

Request (Mode A): { "html": "<!doctype html>…", "slug": "acme-pitch", "title": "Acme" }
Request (Mode B): { "content": "# Title\n\nBody\n\n---\n\n## Slide 2",
                    "content_format": "markdown", "type": "deck", "slug": "acme-pitch", "theme": "vibrant" }
Response: { "data": { "url": "https://acme.reportroom.io/acme-pitch", "documentId": "doc_…",
                      "slug": "acme-pitch", "version": 1,
                      "chartsRendered": 1, "chartErrors": [],
                      "imagesRendered": 2, "imageErrors": [],
                      "artifactKey": "…", "visibility": "public", "status": "live",
                      "scan": { "verdict": "clean", "score": 0, "reasons": [] },
                      "removalsCount": 0, "message": "…" } }

The MCP publish tool returns the same data as structuredContent but in snake_case (document_id, charts_rendered, images_rendered, …) and omits artifactKey, visibility, scan, and removalsCount. There's no content_format on the MCP tool β€” content is always treated as markdown.

GET /v1/documents

List the account's published documents. ?limit= (default 20). Requires auth.

POST /v1/documents/{slug}/unpublish

Retire one of your live documents: its URL starts returning 410 Gone and the plan slot is freed. Idempotent. Requires auth. Returns 404 DOCUMENT_NOT_FOUND if you don't own a live doc with that slug.

Response: { "data": { "slug": "acme-pitch", "status": "unpublished" } }

POST /v1/documents/{slug}/republish

Bring a previously-unpublished document back live. Re-checks the email gate and plan quota exactly like publish (so it can return 403 email_unverified or 409 cap_reached). Requires auth. 404 DOCUMENT_NOT_FOUND if no unpublished doc with that slug is yours.

Response: { "data": { "slug": "acme-pitch", "status": "live" } }

POST /v1/documents/{slug}/export

Render one of your live documents to a print-quality PDF (A4, backgrounds included) and return the bytes (content-type: application/pdf). Requires auth. Costs 5 credits, charged only when the render succeeds β€” a failed render is never charged. Public/unlisted documents only for now (400 EXPORT_UNSUPPORTED_VISIBILITY for team-gated docs).

curl -X POST https://api.reportroom.io/v1/documents/acme-pitch/export \
  -H "authorization: Bearer rr_live_..." \
  -H "Idempotency-Key: my-export-1" -o acme-pitch.pdf

GET /v1/documents/{slug}/analytics

Per-document view stats + a ready-to-relay summary. Requires auth.

Response: { "data": { "slug": "acme-pitch", "url": "https://acme.reportroom.io/acme-pitch",
                      "views7d": 42, "byDay": [{ "day": "2026-07-05", "views": 8 }, …],
                      "message": "\"Acme\" got 42 views in the last 7 days…" } }

GET /v1/handle

Returns your current handle (subdomain) and its URL base. Requires auth. url_base uses rrpreview.com while unverified.

Response: { "data": { "handle": "acme", "url_base": "https://acme.reportroom.io" } }

POST /v1/handle

Rename your subdomain. Moves all your docs to the new handle; old links redirect. Requires auth. Returns 400 HANDLE_REJECTED if the handle is taken, invalid, or reserved.

Request:  { "handle": "acme" }
Response: { "data": { "handle": "acme", "moved": 3, "message": "…" } }

POST /v1/images

Upload an image to reference in your documents. Requires auth + a verified email. Body is the raw image bytes (set the content-type) or JSON { "data": "<base64>", "kind"?, "visibility"? }. PNG, JPEG, WebP or GIF (magic-byte validated β€” no SVG), 2 MB max. Identical bytes at the same visibility dedupe to one slot.

Response 201: { "data": { "id": "img_…", "url": "https://acme.reportroom.io/_img/org_…/<sha256>.png",
                          "path": "/_img/org_…/<sha256>.png", "kind": "doc", "visibility": "public",
                          "sha256": "…", "bytes": 12345, "content_type": "image/png", "deduped": false } }

GET /v1/images

List your images (newest first) with usage: { "data": { "images": […], "used": 7, "cap": 100 } }. Requires auth.

DELETE /v1/images/{id}

Delete an image β€” frees its quota slot immediately and stops its URLs serving (public image URLs may stay cached up to 1h). Requires auth. 404 NOT_FOUND if it isn't yours.

GET /v1/branding Β· PUT /v1/branding Β· DELETE /v1/branding

Your workspace logo. Once set, every document you publish afterwards carries a small logo badge (changes reach each doc on its next publish). Requires auth; PUT/DELETE need the owner or an admin role.

PUT request:  { "image_id": "img_…" }   // must be YOUR image with kind=brand and visibility=public
GET response: { "data": { "logo": { "image_id": "img_…", "path": "/_img/…", "url": "https://…" } } }

PUT validation errors are distinct: 404 NOT_FOUND (not your image / deleted), 400 NOT_BRAND (upload it with kind=brand), 400 NOT_PUBLIC (a team-visibility image would break on public documents). DELETE clears the logo (idempotent).

GET /v1/account

Account status: handle, tier, url_base, org_kind (personal/team), your role in a team workspace, and scopes. Requires auth.

Account deletion is not agent-reachable: DELETE /v1/account exists but accepts only a dashboard session (guarded by typed-email confirmation). An API key or MCP token is rejected 403 FORBIDDEN, so a leaked credential can never destroy the account that owns it.

Data rooms (Business)

A data room is a named, access-controlled bundle of your documents shared with identified external viewers at one link β€” for deal/diligence workflows with per-viewer engagement tracking. Business plan only, owner/admin role. Documents stay first-class: adding one to a room never unpublishes it, and a document that's in a live room is blocked at its own standalone URL so the gate can't be bypassed. The same operations are first-class MCP tools β€” most agents drive rooms from there.

Rooms serve on your handle host: https://<handle>.reportroom.io/room/<slug> (and /room/<slug>/<doc-slug> per document). Limits: 10 live rooms per workspace, 200 viewers per room.

Endpoint Purpose
POST /v1/rooms Create a room: { name, slug?, access_mode?, passcode?, settings? } β†’ { data: { room } }
GET /v1/rooms List your rooms
GET /v1/rooms/{id} One room + its documents and viewers
PATCH /v1/rooms/{id} Update access_mode, passcode (string sets/rotates, null clears), settings, status (live/archived)
PUT /v1/rooms/{id}/documents Ordered replace: { document_ids: [...] } β†’ { data: { documents } }
POST /v1/rooms/{id}/viewers Grant a viewer: { email } β†’ { data: { viewer, invite_token } } (invite_token returned once)
GET /v1/rooms/{id}/viewers List viewers + status
DELETE /v1/rooms/{id}/viewers/{viewerId} Revoke a viewer β€” kills their live sessions immediately
GET /v1/rooms/{id}/analytics Per-viewer engagement (see below)

Access modes (access_mode): public (no gate), email (default β€” anyone may request a magic link), passcode (a shared passcode; provide passcode on create), allowlist (only pre-granted viewers get a link).

settings (JSON, fully replaced on update): nda_text (click-through NDA shown before the room renders; editing it re-prompts everyone), expires_at (epoch ms β€” past it the room returns 410 for everyone), hide_branding (whitelabel the viewer page), crm_webhook_url (https β€” POSTs a { type: "room.lead", email, roomId, … } lead when a viewer requests access). (allow_download and watermark are accepted for forward-compat but not yet enforced β€” don't rely on them.)

Analytics (GET /v1/rooms/{id}/analytics): rows of { viewer_id, document_id, opens, events, dwell_ms }, most-opened first. email/allowlist viewers are identity-linked; public/passcode entries are anonymous (anon_…). Backed by Cloudflare Analytics Engine β€” returns an honest empty result when analytics isn't configured.

Custom domains (Team/Business)

Serve your documents from your own hostname (e.g. reports.acme.com). Team or Business plan, owner/admin role.

Errors: 403 PAYMENT_REQUIRED (free) / 403 plan_required (Pro β€” upgrade to Team/Business), 400 INVALID_HOSTNAME, 409 DOMAIN_TAKEN, 404 DOMAIN_NOT_FOUND, 502 CF_ERROR, 503 NOT_CONFIGURED. Rate limit: 10/hour per workspace.

Agents can use the streaming attach_domain MCP tool instead β€” with an Accept: text/event-stream request it emits notifications/progress frames as provisioning advances and returns the DNS records for the human to create.

POST /v1/lint

Pre-flight check an HTML document before publishing (missing viewport/og, stripped scripts, off-brand). No auth.

Request:  { "html": "<!doctype html>…" }
Response: { "data": { "ok": true, "issues": [ { "level": "warning", "code": "no-og-title", "message": "…" } ] } }

GET /v1/design-system?theme=

Returns everything an agent should follow before authoring HTML. No auth. Response data keys: theme, version, tokens (CSS custom properties), rules (hard constraints), components (ready-to-paste snippets β€” slides, KPI cards, callouts, charts, figures, galleries, …), and shells ({ deck, report } document skeletons); a top-level themes lists the available themes. There is currently one theme β€” vibrant (Midnight Azure: ink-violet headings, azure accents on a white reading surface) β€” which is also the default.

POST /v1/report-abuse

Report an abusive published page. No auth, rate-limited.

Request: { "url": "https://bad-handle.reportroom.io/bad-slug", "reason": "phishing" }

MCP server MCP

ReportRoom ships a Model Context Protocol server so agents (Claude Code, Cursor, claude.ai, …) can publish and track documents natively.

Connect

Claude Code:

claude mcp add --transport http reportroom https://mcp.reportroom.io/mcp
# in a session:
#   1) call create_account (email) -> save the api_key
#   2) re-add with the key:
claude mcp add --transport http reportroom https://mcp.reportroom.io/mcp \
  --header "Authorization: Bearer rr_live_..."

claude.ai (web & desktop): Settings β†’ Connectors β†’ Add custom connector β†’ paste https://mcp.reportroom.io/mcp β†’ Connect. ReportRoom is a full OAuth 2.1 authorization server, so the client discovers auth automatically and walks you through sign-in β€” no key to paste. (Requires a plan that allows custom connectors.)

ChatGPT: custom MCP connectors run in Developer mode (Plus, Pro, Team, Enterprise, or Edu β€” not Free).

  1. Settings β†’ Apps & Connectors β†’ Advanced β†’ turn on Developer mode.
  2. Settings β†’ Connectors β†’ Create β†’ name it "ReportRoom", set the connector URL to https://mcp.reportroom.io/mcp.
  3. Authenticate when prompted (OAuth).

Codex CLI: add a streamable-HTTP server to ~/.codex/config.toml, then sign in:

[mcp_servers.reportroom]
url = "https://mcp.reportroom.io/mcp"
# optional β€” API key instead of OAuth:
# bearer_token_env_var = "REPORTROOM_KEY"
codex mcp login reportroom   # OAuth sign-in
# verify with /mcp in the Codex TUI

OAuth endpoints (for connectors that discover auth automatically)

Auth-required tools answer 401 with a WWW-Authenticate challenge pointing at the metadata above, which is what kicks off the connector's OAuth flow. Discovery and create_account stay open so agents can bootstrap with zero setup.

Tools

23 tools. Every tool returns human-readable text plus structuredContent; errors set isError with actionable guidance.

Author & publish:

Tool Purpose Auth
create_account Bootstrap a free account; returns an API key (shown once) no
get_design_system Design tokens + component snippets + rules β€” call before authoring HTML no
list_themes Available design themes no
lint_document Pre-flight check HTML before publish no
publish Publish/update a deck or report (Mode A html or Mode B content+type); idempotent on slug; optional visibility (team = members-only, paid) and cover_image; returns the full live URL. Requires a verified email yes
list_documents List your published documents yes
unpublish Retire a live document: its URL returns 410 Gone and the plan slot frees. Idempotent yes
republish Bring an unpublished document back live (re-checks the email gate + plan quota) yes
get_analytics Per-document views + a summary you can relay to the human yes

Images (reference the returned path in your document; an image-only paragraph becomes a styled figure, consecutive ones a gallery):

Tool Purpose Auth
upload_image Upload base64 image bytes (PNG/JPEG/WebP/GIF, 2 MB max, plan caps; kind=brand for a logo, visibility=team for members-only) yes
list_images Your hosted images with used/cap yes
delete_image Delete by id β€” frees the slot; URLs stop serving in seconds yes

Export:

Tool Purpose Auth
export_pdf Render a live document to a print-quality PDF (5 credits; idempotency_key makes retries charge once). Returns a signed download URL valid 1 hour yes

Account & domains:

Tool Purpose Auth
account_status Tier, limits, handle, your role in a team workspace yes
set_handle Rename your account's subdomain (handle); moves all your docs, old links redirect yes
attach_domain Attach your own hostname (Team/Business): streams provisioning progress, returns the DNS records for the human to create yes

Data rooms (Business β€” gated deal-room collections with per-viewer tracking):

Tool Purpose Auth
create_data_room Create a room (name, slug) to share documents behind a gate yes
add_documents_to_room Set which of your documents the room contains yes
set_room_access Configure the gate: email verification, passcode, NDA click-through, expiry, allowlist yes
grant_room_access / revoke_room_access Manage individual viewers yes
list_room_viewers Who has access + status yes
get_room_analytics Per-viewer engagement (opens, dwell, which documents) β€” the "who read the deck" answer yes

URLs

Each account gets a handle β€” a subdomain, auto-generated on signup (an opaque token like u7k2m9qp) and renameable any time via set_handle (e.g. to acme). Published docs live at https://<handle>.reportroom.io/<slug>.

Publishing requires a verified email. A new account gets an API key but is unverified; publish fails with email_unverified until the human clicks the verification link (check the inbox after create_account). While unverified, account_status reports a preview base on rrpreview.com (noindex) β€” the base your links will use once verified; you can't publish there beforehand.

publish is idempotent on slug (per account) and returns the full url in structuredContent.

Recommended flow

create_account (then have the human verify their email β€” publishing is blocked until they do) β†’ get_design_system β†’ author self-contained HTML following the tokens β†’ upload_image for any figures/logo β†’ lint_document β†’ publish (with cover_image for a hero) β†’ later, get_analytics to report back who viewed it. Optionally set_handle once to pick a nicer subdomain (old links redirect), export_pdf when the human wants a file, and unpublish/republish to retire or restore a document. For deal workflows on Business: create_data_room β†’ add_documents_to_room β†’ set_room_access β†’ share the room link β†’ get_room_analytics to report per-viewer engagement.