Developer Docs
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.
https://api.reportroom.iohttps://mcp.reportroom.io/mcpllms.txtBase URL: https://api.reportroom.io Β· Versioned under /v1.
# 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"}'
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:
UNAUTHENTICATED (401), FORBIDDEN (403), email_unverified (403), INVALID_TOKEN (400), INVALID_EMAIL, DISPOSABLE_EMAIL, EMAIL_EXISTS (409), HANDLE_REJECTED, USER_NOT_FOUND (404).INVALID_BODY, EMPTY_DOCUMENT, INVALID_SLUG, INVALID_VISIBILITY, INVALID_COVER_IMAGE, IMAGE_LIMIT (>20 images), CONTENT_BLOCKED, bad_replace, cap_reached (409, carries cap + live[]), PLAN_REQUIRED (403, team visibility), DOCUMENT_NOT_FOUND (404).too_large (413, >2 MB), unsupported_format (400), content_blocked / held_for_review (422), cap_reached (409, carries cap + used); branding: NOT_FOUND, NOT_BRAND, NOT_PUBLIC.INSUFFICIENT_CREDITS (402, carries required + balance), IDEMPOTENCY_CONFLICT (402), INVALID_IDEMPOTENCY_KEY (400), EXPORT_UNSUPPORTED_VISIBILITY (400), RENDER_FAILED / STORAGE_FAILED (502).ROOM_NOT_FOUND (404), VIEWER_NOT_FOUND (404), ROOM_LIMIT (409, β€10 rooms), VIEWER_LIMIT (409, β€200 viewers), plan_required / PLAN_REQUIRED (403, Business).INVALID_HOSTNAME, DOMAIN_TAKEN (409), DOMAIN_NOT_FOUND (404), PAYMENT_REQUIRED / plan_required (403), CF_ERROR (502).RATE_LIMITED (429), NOT_FOUND (404), TOO_LARGE (413), NOT_CONFIGURED (503).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.
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.
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.
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": "β¦" } }
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
publishtool returns the same data asstructuredContentbut in snake_case (document_id,charts_rendered,images_rendered, β¦) and omitsartifactKey,visibility,scan, andremovalsCount. There's nocontent_formaton the MCP tool βcontentis always treated as markdown.
403 email_unverified otherwise (see trust tiers).get_design_system first for on-brand output).<script type="application/json" data-qd-chart>{β¦ECharts optionβ¦}</script> β rendered to static SVG at publish.path. An image-only paragraph renders as a styled figure (an adjacent italic line becomes its caption); consecutive image-only paragraphs become a responsive gallery grid. Up to 20 images per document.cover_image (a /v1/images path or an https URL): full-bleed cover behind a deck's first slide, or a top band on a report. Counts toward the 20-image budget; a bad value is 400 INVALID_COVER_IMAGE.slug optional (auto-generated if omitted). Reserved slugs (api, app, admin, mcp, dashboard, β¦) are rejected.replace_slug: retire one of your live documents to free a plan slot for this publish. Optional visibility: public (default) or team (members-only; needs an active Team or Business plan, else 403 PLAN_REQUIRED).409 cap_reached (the response lists your live docs) β reuse a slug or pass replace_slug.List the account's published documents. ?limit= (default 20). Requires auth.
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" } }
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" } }
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
Idempotency-Key (optional, 1β64 chars): a retried export with the same key re-renders but charges exactly once per document version. Response headers x-export-charged and x-export-credits-remaining report the burn.402 INSUFFICIENT_CREDITS (with required and balance) when the wallet can't cover it β top up from the dashboard.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β¦" } }
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" } }
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": "β¦" } }
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 } }
kind=brand (a logo asset β doesn't count against any per-document budget) Β· visibility=team (serves only to signed-in workspace members; needs Team/Business, else 403 PLAN_REQUIRED).path in your HTML/markdown β it survives handle renames.409 cap_reached with cap and used.List your images (newest first) with usage: { "data": { "images": [β¦], "used": 7, "cap": 100 } }. Requires auth.
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.
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).
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.
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.
Serve your documents from your own hostname (e.g. reports.acme.com). Team or Business plan, owner/admin role.
POST /v1/domains β { "hostname": "reports.acme.com" } β { data: { hostname, status, dns, message } }. dns is the records to create at your DNS provider: a CNAME pointing the hostname at ReportRoom's target, plus any TXT records for hostname-ownership and TLS-certificate validation. Create them, then poll status.GET /v1/domains β { data: { domains: [{ hostname, status, dns? }] } }. status moves pending β pending_validation β active; dns is included until it's active. Once active, your documents' url switches to the custom host.DELETE /v1/domains/{hostname} β detaches β { data: { deleted: "reports.acme.com" } }.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.
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": "β¦" } ] } }
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.
Report an abusive published page. No auth, rate-limited.
Request: { "url": "https://bad-handle.reportroom.io/bad-slug", "reason": "phishing" }
ReportRoom ships a Model Context Protocol server so agents (Claude Code, Cursor, claude.ai, β¦) can publish and track documents natively.
https://mcp.reportroom.io/mcp2025-11-25, 2025-06-18, 2025-03-26.Authorization: Bearer rr_live_β¦ (Claude Code). No key yet? Call the create_account tool β it returns one.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).
https://mcp.reportroom.io/mcp.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
GET /.well-known/oauth-protected-resourceGET /.well-known/oauth-authorization-serverPOST /oauth/registerGET /oauth/authorize β POST /oauth/token (+ refresh_token)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.
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 |
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.
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.