ReviewMySites · Developers
API and webhooks
Read your projects and comments, move them along, reply, and hear about every new comment and change as it happens. Both are part of Pro.
Authentication
Make a key in Workspace settings → API keys (owners and admins). It is shown once. Send it as a bearer token:
curl https://app.reviewmysites.com/api/v1/me \ -H "Authorization: Bearer rms_live_…"
A key reaches every project in its workspace and acts as the person who made it: changes are logged under their name. It stops working when it is revoked, when that person is no longer an owner or admin of the workspace, or when the workspace leaves Pro. Keep keys on a server; the API sends no CORS headers, so a browser page cannot use one.
Errors and limits
Every error has one shape:
{ "error": { "code": "not_found", "message": "No such comment in this workspace." } }401no key, a revoked key, or a key whose maker left (invalid_key,key_inactive)402the workspace is not on Pro (plan_required)403not allowed, or the workspace is read-only (read_only)404not found, or not in this key's workspace (the two look the same)429rate limited: 120 reads and 60 writes a minute, per key
Projects
GET/api/v1/projects
Every project in the workspace: id, handle (its project ID, like acme-website), name, site_url, board_url.
GET/api/v1/projects/{project}
One project, by uuid or project ID, with what a comment can be set to: statuses (key, label), tags (id, name) and the members who can be assigned (id, name, role).
Comments
GET/api/v1/projects/{project}/comments
Newest first, 50 a page (?limit= up to 100). Filter with ?status=resolved and ?updated_since=2026-11-01T00:00:00Z; archived ones only with ?include_archived=true. Follow next_cursor with ?cursor=.
GET/api/v1/comments/{id}
One comment:
{
"id": "6f0c…", "number": 18, "project_id": "…",
"text": "The checkout button overlaps the footer", "html": "<p>…</p>",
"status": { "key": "open", "label": "Open" }, "priority": "urgent",
"pinned": true, "page_url": "https://acme.com/pricing",
"url": "https://app.reviewmysites.com/project/acme-website/18",
"site_url": "https://acme.com/pricing?rms=acme-website&rms_c=18",
"author": { "name": "Maya", "guest": false },
"assignee": { "id": "…", "name": "Sam" }, "due_on": "2026-11-12",
"tags": [{ "id": "…", "name": "Checkout" }],
"screenshot_url": "https://…", "attachments": [],
"device": "desktop", "browser": "Chrome 141", "os": "macOS",
"viewport": { "width": 1440, "height": 900 },
"created_at": "…", "updated_at": "…", "resolved_at": null, "archived_at": null
}PATCH/api/v1/comments/{id}
Change any of status (a status key), priority (urgent, normal, minor), assignee_id (a member's id, or null), due_on (YYYY-MM-DD or null) and tag_ids (up to 5 of the project's tags). Any other field is refused. It does exactly what the board does: the history, the notifications and your integrations follow. Answers with the comment.
curl -X PATCH https://app.reviewmysites.com/api/v1/comments/6f0c… \
-H "Authorization: Bearer rms_live_…" -H "Content-Type: application/json" \
-d '{ "status": "resolved" }'POST/api/v1/comments/{id}/replies
Reply with { "text": "Fixed in today's deploy" }, plain text up to 5,000 characters. Refused with 409 where the project keeps its conversations in a chat app.
Subscribing (REST hooks)
POST/api/v1/projects/{project}/hooks
Subscribe a URL to a project: { "url": "https://…", "events": ["comment.created"] }. Leave events out for all of them. Answers with the hook's id and its signing_secret, once. It shows in Project settings as added through the API.
DELETE/api/v1/hooks/{id}
Unsubscribe. Only hooks made through the API; ones added in Project settings are removed there.
Webhooks
Add one in Project settings → Integrations → Work tools, or subscribe through the API. Each event is a POST with a JSON body to your public https URL (port 443 or 8443). Answer with any 2xx within 10 seconds; we do not follow redirects and do not read your answer.
A timeout, 408, 429 or 5xx is tried again up to five more times, waiting at least 1, 5, 30, 120 and 720 minutes. Retries go out with the project's next event or a daily sweep, so in a quiet project one can take until the next day. Any other answer, a redirect included, is not retried. After 5 deliveries in a row have failed for good, the webhook pauses and the owner is emailed; resuming it or giving it a new URL starts the count again.
comment.created, and with itcomment.updated(words, priority, due date, tags, page, screenshot or files changed;changessays which) andcomment.deletedcomment.replied,comment.status_changed(a resolve is one),comment.assignedping, from Send a test
Headers: RMS-Event, RMS-Delivery (the event id, the same on every retry: use it to ignore repeats) and RMS-Signature.
{
"version": 1,
"id": "0b6e…", "type": "comment.status_changed", "occurred_at": "2026-11-03T09:12:44Z",
"project": { "id": "…", "name": "Acme website", "url": "…", "site_url": "https://acme.com" },
"comment": { "id": "…", "number": 18, "text": "…", "status": { "key": "resolved", "label": "Resolved" }, … },
"actor": { "name": "Sam" },
"change": { "from": { "key": "open", "label": "Open" }, "to": { "key": "resolved", "label": "Resolved" } },
"reply": null
}No emails are ever sent. Console and network logs only when chosen in the webhook's What to send.
Verifying a delivery
RMS-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of t + "." + raw body with your signing secret. Use the raw body, before any JSON parsing, compare in constant time, and refuse anything older than five minutes.
// Node
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const want = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1 ?? '', 'hex');
return got.length === want.length && timingSafeEqual(got, want);
}# Python
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
want = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, parts.get("v1", ""))API version 1. Every answer carries X-RMS-API-Version.