All postsIntegrations

Receive website feedback with signed webhooks

Every new comment, reply and change as signed JSON at a URL you choose: the events, the signature, and retries.

ReviewMySites team · 5 min read

A webhook sends every new comment, reply and change on a project to a URL you choose, as signed JSON. Use it to open tickets in your own tracker, feed a dashboard, or start an automation. Webhooks are part of Pro.

Add one

  1. Open the project's settings

    Use Settings in the ⋯ on the project's card or the gear on its board, then choose Integrations and find Work tools. You need to be an owner or admin.

  2. Press Add beside Webhook

    Enter your endpoint's URL and a name. It must be public https, on port 443 or 8443.

  3. Copy the signing secret

    It is shown once. You can make a new one later, which stops the old one at once.

  4. Choose the events

    Open What to send from the webhook's ⋯. New webhooks start with new comments, replies and status changes.

  5. Send a test

    Your endpoint receives a ping made from the project's latest comment.

No endpoint yet?Point it at a free request inspector such as webhook.site, on a test project, to see exactly what arrives. Those URLs are public, so do not leave real feedback flowing there.

What it sends

EventWhenChosen as
comment.createdA new commentA new comment arrives
comment.updatedIts words, priority, due date, tags, page, screenshot or files changedComes with new comments
comment.deletedIt was deletedComes with new comments
comment.repliedSomeone repliedSomeone replies
comment.status_changedIts status changed, including a resolveThe status changes
comment.assignedSomeone was assignedSomeone is assigned (off to start with)
pingYou pressed Send a testAlways

A comment is saved before its screenshot has finished uploading, so comment.created usually has no picture yet. A comment.updated with { "kind": "screenshot" } in changes follows when it lands.

A deliveryPOST /your/endpoint
Content-Type: application/json; charset=utf-8
RMS-Event: comment.status_changed
RMS-Delivery: 0b6e…
RMS-Signature: t=1793700000,v1=5f2a…

{
  "version": 1,
  "id": "0b6e…",
  "type": "comment.status_changed",
  "occurred_at": "2026-11-05T09:12:44Z",
  "project": { "id": "…", "name": "Acme website", "url": "…", "site_url": "https://acme.com" },
  "comment": {
    "id": "…", "number": 18, "text": "The checkout button overlaps the footer",
    "status": { "key": "resolved", "label": "Resolved" }, "priority": "urgent",
    "page": "Pricing", "page_url": "https://acme.com/pricing", "assignee": "Sam",
    "due_on": "2026-11-12", "tags": ["Checkout"], "screenshot_url": "https://…",
    "url": "…", "site_url": "…"
  },
  "actor": { "name": "Sam" },
  "change": { "from": { "key": "open", "label": "Open" }, "to": { "key": "resolved", "label": "Resolved" } },
  "reply": null
}

Email addresses are never sent. The console and network logs are left out unless you tick them in What to send.

Check the signature

Your URL is public, so anyone could post to it. RMS-Signature proves a delivery came from us: v1 is the HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your signing secret. Use the raw body before any JSON parsing, and refuse anything older than five minutes.

Nodeimport { 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);
}
Pythonimport 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", ""))

Answering, retries and pausing

  • Accept with any 2xx, within 10 seconds. Do slow work after you answer.
  • A timeout, 408, 429 or 5xx is tried again, up to five more times, with growing gaps. 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.
  • Anything else is not retried, a redirect included. Redirects are never followed.
  • The same event can arrive twice. RMS-Delivery is the same on every retry, so use it to ignore repeats.
  • After 5 deliveries in a row have failed for good, the webhook pauses and the workspace owner gets an email. Resume it from its ⋯, or give it a new URL.

Recent posts, in the webhook's ⋯, shows what was delivered and what went wrong. Send a test is not listed there: its result shows straight away.

Using an automation tool

Zapier's Catch Hook, Make's custom webhook and n8n's webhook node all give you an https URL to paste here, which is a quick way to reach apps we do not connect to yet. They do not check the signature unless you add a step for it, so keep that URL private.

Prefer to subscribe from code? The REST API can add and remove webhooks too. The full reference is in the developer docs.

Keep reading

All posts

Let your client point at it.

Free for your first project. No card, and your clients never need an account.

Start free