Each app can send its events to a Discord or Slack channel, or to any HTTPS address as signed JSON. Set them up in the app's Integrations tab; each destination has a Test button that delivers a sample report.
Events
| Event | When |
|---|---|
report.created | A new report arrives. Reports locked for being over the plan's limit are skipped. |
report.reporter_replied | The reporter replies by email or on the tracking page. The body has a "message". |
report.status_changed | Someone, or Claude over MCP, changes the status. The body has a "change" with "from", "to" and the actor. |
report.staff_message | A teammate replies or adds an internal note. Off by default. |
Discord and Slack
Paste a channel webhook address. Messages show the report's number, type, priority and source with a link; a new report's message has its title too. Turn on "Include the description and reporter…" only for private channels.
JSON webhook
A POST to your HTTPS address with these headers:
X-Snag-Event: the event name.X-Snag-Delivery: unique per delivery; a retry carries the same one.X-Snag-Signature:t=<unix time>,v1=<hex>, an HMAC-SHA256 oft.bodywith the integration's secret.
{
"id": "…",
"event": "report.created",
"created_at": "2026-10-12T09:30:00Z",
"app": { "id": "…", "slug": "your-app", "name": "Your App" },
"report": {
"id": "…", "number": 42, "kind": "bug",
"title": "Export crashes on Sonoma", "description": "…",
"status": "open", "priority": "none", "labels": [], "source": "web",
"url": "https://snagfrog.com/dashboard/reports/…",
"reporter": { "name": "Jane", "email": "jane@example.com" },
"diagnostics": { "version": "1.4.2", "os": "macOS 15.1" },
"created_at": "2026-10-12T09:30:00Z"
}
} Checking the signature
Check it against the raw body, before parsing, and refuse anything older than a few minutes:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret, toleranceSeconds = 300) {
try {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
const given = Buffer.from(v1);
return expected.length === given.length && timingSafeEqual(expected, given);
} catch {
return false;
}
} Retries
- A delivery that fails with 429, a 5xx status, a network error or a timeout is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. A
Retry-Afterheader can lengthen a wait. - Other 4xx answers are final.
- After 20 failures in a row the integration turns itself off and the workspace is emailed.
Issue trackers
Also on the Studio plan: connect GitHub or Linear and create an issue from a report with one click. That is a separate connection under Settings → Connections, not a webhook.
Questions
Which plan includes webhooks, Discord and Slack?
The Studio plan ($29 a month), and the 30-day trial, which includes Studio features. On the Indie plan, each report can be copied as Markdown.
Is the text of internal notes ever posted to a channel?
Not to Discord or Slack: the staff-message event is off by default, and when it is on, their messages never contain the text of an internal note. The JSON webhook is different: it goes to your own server, and its "message" does carry the note.
How do I ignore a delivery I already handled?
Every delivery has a unique X-Snag-Delivery header, which is also the "id" in the body. A retry carries the same one, so store the ids you have handled and skip repeats.
More reference pages
Start a free 30-day trial · All docs · Guides · Compare with other tools · Changelog