Webhook
Send issue events to a custom URL — for integrations beyond Slack and GitHub.
Table of Contents
Use webhooks for integrations beyond the built-in Slack · GitHub · Vercel paths. Whenever an issue is created, updated, or commented on, a JSON payload is POSTed to the URL you set. Linear · Jira · Discord bots · Notion databases — anything that can receive HTTP can plug in.
Use Cases
| System | Use |
|---|---|
| Linear | Mirror QA Note issues into Linear |
| Jira | Push only critical-priority issues into the Jira backlog |
| Discord | Notify the design channel on issues with the design label |
| Notion | Append issue metadata as rows in a Notion DB |
| Custom alerts | Internal Slackbot · Teams · email · SMS gateway |
If a direct integration already covers the path (Slack · GitHub), there is no need to duplicate it via webhook. Webhooks are the fallback for everything else.
How to Set Up
In the dashboard:
Project Settings → Integrations → Webhook → enter URL · Secret
Path: /settings/projects/[projectId]/integrations/webhook
| Field | Description |
|---|---|
| URL | External endpoint that receives POST requests (HTTPS only) |
| Secret | Random string for HMAC-SHA256 signature verification (32+ chars recommended) |
| Subscribed events | Which issue.* events to receive |
| Active toggle | Pause without deleting |
Only HTTPS URLs are accepted. localhost and private IPs are rejected (use a tunnel like cloudflared during development).
Event Types
| Event | Trigger |
|---|---|
issue.created | A new issue is created (Extension · Widget · dashboard) |
issue.updated | Title · body · priority change |
issue.status_changed | Status moves (open → in_progress, etc.) |
issue.commented | A comment is added |
issue.assigned | Assignee changes |
issue.label_added · issue.label_removed | Labels change |
issue.merged | A linked PR merges and the issue auto-closes (AI Fix · GitHub) |
issue.deleted | Issue is deleted (trash and permanent) |
You can subscribe to multiple events. A project may register up to 5 webhooks today.
Payload Schema
The POST body is JSON in this shape.
{
"id": "evt_01H...",
"type": "issue.created",
"project": { "id": "proj_xyz", "slug": "acme-app" },
"issue": {
"id": "iss_abc",
"number": 42,
"title": "Login error",
"status": "open",
"priority": "high",
"url": "https://qanote.app/i/abc-42"
},
"actor": { "id": "usr_123", "name": "Jane Doe", "email": "jane@example.com" },
"timestamp": "2026-05-01T09:30:12.000Z"
}
| Field | Type | Description |
|---|---|---|
id | string | Event ID (stable across retries — usable as an idempotency key) |
type | string | Event type (see Event Types) |
project | object | Project the issue belongs to |
issue | object | Core issue fields (load full metadata via url if needed) |
actor | object | null | The user who triggered the event. null for system-triggered changes (e.g., AI Fix auto-close) |
timestamp | ISO 8601 | Event time (UTC) |
Rich issue metadata (console logs, screenshots, etc.) is not included in the payload. Re-fetch via issue.url or MCP resolve_by_url when needed.
Signature Verification (HMAC)
Each webhook request includes these headers.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | QANote-Webhook/1.0 |
X-Qanote-Event | Event type, e.g., issue.created |
X-Qanote-Delivery | Event ID (evt_01H...) |
X-Qanote-Signature | sha256=<HMAC-SHA256(secret, rawBody)> |
Compute HMAC over the raw body with your secret and compare timing-safe.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, signatureHeader: string, secret: string): boolean {
// Header format: "sha256=<hex>"
const [scheme, signature] = signatureHeader.split("=");
if (scheme !== "sha256" || !signature) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
// Bail early on length mismatch (timingSafeEqual throws on different lengths)
if (expected.length !== signature.length) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}
Compute the HMAC over the raw body string, not a re-serialized JSON object. Re-stringifying changes whitespace and key order, breaking the signature.
Retry Policy
Non-2xx responses or timeouts (10s) trigger retries.
| Attempt | Delay |
|---|---|
| 1st | Immediate |
| 2nd | After 30 sec |
| 3rd | After 5 min |
| 4th | After 30 min |
| 5th (final) | After 4 hours |
After the 5th attempt the event is dropped and recorded under Project Dashboard → Webhook → "Recent failures" with a red marker. If the same webhook fails 50+ consecutive times within 24 hours, it is auto-disabled and you are notified.
X-Qanote-Delivery stays the same across retries. Use it as an idempotency key on the receiving side.
Troubleshooting
Body is empty on receipt
- A JSON parser ran before your handler captured the raw body. Preserve the raw body for HMAC verification (in Next.js Route Handlers, call
await request.text()once and reuse). - Proxy stripped the body — check body-size limits on Cloudflare · nginx · API Gateway (1MB recommended).
Signature verification fails
- A trailing newline got added or removed in the raw body. Hand the bytes to HMAC exactly as received.
- The secret was rotated in the dashboard but the receiver still uses the old value. Rotation has no grace window — update both sides at once.
- Make sure your parser keeps the
sha256=prefix in mind when splitting the header.
Too many retries
- Your endpoint is returning 5xx or timing out. Check Project Dashboard → Webhook → "Recent failures" for status codes and response bodies.
- To defer processing, return 200 immediately and handle async. 5xx triggers retries.