What a webhook sends
A webhook endpoint receives an HTTPS POST with a JSON body when a comment in your workspace is created, changes status, is edited, or gets its screenshot. It covers every website, web app, and media project in the workspace, and private comments are sent too. There are four events:
| Event | Sent when |
|---|---|
comment.created | A comment is left |
comment.status_changed | A comment's status changes |
comment.text_changed | A comment's text is edited |
comment.screenshot_ready | A comment's screenshot is saved (not on media projects) |
A new website or web app comment is held for at least ten seconds before it is sent, so its screenshot is usually ready and included in comment.created. comment.screenshot_ready is still sent afterwards, so expect both for most website and web app comments. Comments on media projects and the other three events are not held.
Replies, deleted comments, and other changes, such as priority, tags, assignees or a replaced screenshot, send nothing. Events for one comment arrive in order, so a retry can hold back later events for the same comment.
Webhooks are on the Team plan, and only workspace owners and admins can set them up. For an overview of what webhooks and the API are for, see Webhooks & API.
Add an endpoint
- Open Integrations in your workspace sidebar.
- In the Webhooks card, click Add endpoint.
- Enter the Endpoint URL. It must start with
https://. - Optionally fill in Description (optional). The list shows it as the endpoint's name, or the host name, such as
hooks.example.com, when there's no description. - Click Add endpoint.
- Copy the signing secret from Save your signing secret, then click I've saved it.
The secret starts with whsec_. It is shown once and can't be retrieved later. If you lose it, rotate the secret to get a new one.
An endpoint added in the app receives all four events. To subscribe to only some of them, create the subscription through the API instead. See Using the REST API.
What each request contains
Every request carries these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Huddlekit-Webhooks/1 |
X-Huddlekit-Event | The event name, such as comment.created |
X-Huddlekit-Event-Id | A unique id for the event, the same on every retry |
X-Huddlekit-Signature | The signature. See below |
The body looks like this, trimmed:
{
"event": "comment.status_changed",
"event_id": "8f14e45f-ceea-467a-9a3b-6c1d2b0e5a71",
"occurred_at": "2026-09-28T09:14:03.512345+00:00",
"workspace_id": "3c59dc04-8d34-4f1b-a6a1-0e2f7b9d4c12",
"surface": "website",
"parent_id": "b6d767d2-f8e3-4c1a-9b2e-5a7c3d1f0e84",
"source": "app",
"comment": {
"id": "c9f0f895-fb98-4b91-99f5-1a2b3c4d5e6f",
"number": 42,
"title": "The signup button overlaps the footer on mobile",
"text": "The signup button overlaps the footer on mobile",
"status": "resolved",
"page": { "path": "/pricing" },
"permalink": "https://app.huddlekit.com/project/b6d767d2-f8e3-4c1a-9b2e-5a7c3d1f0e84?commentId=c9f0f895-fb98-4b91-99f5-1a2b3c4d5e6f",
"screenshot": "https://…",
"browser_info": { "userAgent": "Mozilla/5.0 …", "viewport": { "width": 390, "height": 844 } },
"author": { "name": "Sam", "kind": "guest" },
"created_at": "2026-09-28T08:50:11.204+00:00"
},
"changed": { "status": { "old": "open", "new": "resolved" } }
}
surfaceiswebsite,webapp, ordocument(a media project), and decides the shape ofpage: apathfor a website, aurl,path, andtitlefor a web app, and apage_number(plusvideo_timestampon a video) for a media project.statusis one ofopen,in-review,in-progress, orresolved.changedisnullforcomment.created. For a text edit it holds the new text only; the old text is alwaysnull, so text someone removed is never sent on.screenshotandbrowser_infoare alwaysnullfor media projects.sourcesays who made the change:appfor a change made in Huddlekit or synced back from Slack, Linear, ClickUp or Notion,mcpfor an AI tool connected over MCP, orconnector:<api key id>for a call to the API.permalinkopens the comment in Huddlekit. For a web app comment, it opens the page in your own app where the comment was left.
The full schema for each event is in the OpenAPI description at huddlekit.com/openapi.json.
Verify the signature
X-Huddlekit-Signature looks like t=1790496000,v1=5f2b…. t is a Unix timestamp in seconds, and v1 is a hex HMAC-SHA256 of the timestamp, a period, and the raw request body, keyed with your full signing secret, whsec_ included.
To verify a request:
- Read the raw body exactly as it arrived. Parsing the JSON and serializing it again changes the bytes and the signature won't match.
- Compute the HMAC of
{t}.{raw body}with your secret. - Accept the request if it matches any
v1in the header. There are two while a secret is being rotated. - Reject it if
tis more than five minutes away from your server's clock.
In Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function isFromHuddlekit(header, rawBody, secret) {
let timestamp = null;
const signatures = [];
for (const part of (header ?? '').split(',')) {
const i = part.indexOf('=');
if (i === -1) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't' && timestamp === null) timestamp = value;
if (key === 'v1') signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
return signatures.some(
(sig) =>
/^[0-9a-f]{64}$/i.test(sig) &&
timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))
);
}
Respond, retries, and duplicates
Answer with any 2xx status within ten seconds. Anything else counts as a failed delivery: another status, a timeout, a connection error, or a redirect, which is never followed.
A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours. That is six attempts over about eight and a half hours. After the sixth failure the event is set aside and not sent again.
A retry only goes to the endpoints that haven't answered 2xx for that event yet. Delivery is still at least once, so the same event can occasionally arrive twice. Use X-Huddlekit-Event-Id, which matches event_id in the body and stays the same on every retry, to ignore repeats. Don't de-duplicate on the signature, because its timestamp changes with each attempt.
An endpoint is switched off once it has failed 20 attempts in a row, retries included, and has been failing for more than 24 hours. A single success resets the count, so a short outage or a restart won't switch it off. A switched-off endpoint shows Auto-disabled, with the reason underneath. Events from while it was switched off aren't sent later. Fix the endpoint, then choose Re-enable from its menu, which also starts the count again.
Manage an endpoint
Each endpoint's ⋯ menu has:
- Delivery log — every attempt from the last 30 days, with the status code or No response, the attempt number, how long it took, the error, when the next retry is due, and, for failures, the start of your endpoint's response. The newest 50 show first, and Show more loads up to 200.
- Send test event — sends a
pingevent with a made-up comment to check the URL and your signature code. Each test gets a newevent_id, sent in theX-Huddlekit-Event-Idheader like a real event. It has nosourceorpermalink. It appears in the delivery log. - Pause and Resume — a paused endpoint receives nothing, and events from while it was paused are not sent later.
- Rotate secret — creates a new signing secret and shows it once. The old one keeps working for 24 hours, and during that time each request is signed with both, so you can deploy the new secret without missing deliveries. Only one old secret is kept, so rotating again within 24 hours cuts off the oldest straight away.
- Remove — deletes the endpoint and its delivery log straight away.
A subscription created with an API key, such as a Zap's, shows Key revoked once its key is revoked, with "Its API key no longer works. Remove it and connect again." It can't be re-enabled, only removed.
Good to know
- URLs — the endpoint must be a public
https://address. Addresses on private or local networks aren't accepted. - Plan — if the workspace leaves the Team plan, or a payment is past due, endpoints show Paused and events are not delivered. Events from that time aren't sent later, even after you upgrade again. Until then, only Pause and Remove work.
- Avoiding loops — if your code writes comments back through the API, ignore events whose
sourceis your own key'sconnector:<id>.GET /mereturns that value. A subscription created with an API key never receives the events that key caused. - Zapier uses these same webhooks behind the scenes. See Connecting Zapier.
- Plans are compared in Understanding plans and pricing.