Webhooks
Receive signed JSON events from CEC Feedback at your own endpoint, and verify them.
A webhook integration makes CEC Feedback POST a signed JSON body to a URL you run whenever one of
the events you picked happens in the project.
Set it up
- Open the project's Integrations tab and turn on Webhook.
- Enter a Name and your endpoint's URL, and choose the Events.
- Save. CEC Feedback shows the signing secret (
whsec_…) once: store it with your receiver. If you lose it, delete the integration and connect a new one.
Test in the integration's Settings sends a ping event, so you can check your endpoint.
Events
| Event | type | data |
|---|---|---|
| New feedback | feedback.created | { feedbackId } |
| Feedback edited | feedback.updated | { feedbackId, changes }, with changes.<field> set to [before, after] for the title, description, visibility or tags |
| Status changed | feedback.status_changed | { feedbackId, from, to }, each open or resolved |
| Feedback archived | feedback.archived | { feedbackId } |
| New comment | comment.created | { feedbackId, commentId } |
| Comment edited | comment.updated | { feedbackId, commentId } |
| Comment deleted | comment.deleted | { feedbackId, commentId } |
| Test | ping | {}, and no feedback |
Payload
{
"id": "evt_01JC8Z6Q0M8T3V7R2K9W4X5Y6Z",
"type": "feedback.created",
"occurredAt": "2026-11-05T09:30:00.000Z",
"projectId": "prj_01JC8Z5V1B2C3D4E5F6G7H8J9K",
"data": { "feedbackId": "fb_01JC8Z6P9A1B2C3D4E5F6G7H8J" },
"feedback": {
"id": "fb_01JC8Z6P9A1B2C3D4E5F6G7H8J",
"projectId": "prj_01JC8Z5V1B2C3D4E5F6G7H8J9K",
"number": 142,
"kind": "screenshot",
"title": "Hero button overlaps the menu",
"status": "open",
"visibility": "everyone",
"pageUrl": "https://example.com/pricing?utm_source=x",
"pageUrlNormalized": "https://example.com/pricing",
"anchor": null,
"thumbnailAttachmentId": "att_01JC8Z6P7Q8R9S0T1V2W3X4Y5Z",
"commentCount": 0,
"reporter": { "id": "rep_01JC8Z6M2N3P4Q5R6S7T8V9W0X", "name": "Jane Doe", "email": null },
"tagIds": [],
"consoleErrorCount": 2,
"screenshotStatus": "pending",
"screenshotMethod": null,
"createdAt": "2026-11-05T09:30:00.000Z",
"updatedAt": "2026-11-05T09:30:00.000Z",
"resolvedAt": null,
"createdByUserId": null,
"designId": null
}
}feedback is the item as it is when the delivery runs, not when the event happened. Fetch anything
else through the REST API. To protect reporters and the sites they report on, a payload
never includes reporter emails (reporter.email is always null), console or network logs, or
file URLs: attachments are referenced by ID only.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | CEC-Feedback-Webhooks/1 |
X-Feedback-Event | The event type, such as feedback.created |
X-Feedback-Event-Id | The event id, the same on every retry |
X-Feedback-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Verifying the signature
v1 is the lowercase hex HMAC-SHA256, keyed with your whsec_… secret, of the string
"<t>.<raw body>". Check it before you trust anything in the body:
- Use the raw request body exactly as received. Parsing and re-serialising the JSON changes the bytes.
- Recompute the HMAC and compare it to
v1in constant time. - Reject timestamps more than 5 minutes away from your clock, so a captured request can't be replayed later.
In Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyFeedbackSignature(secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=', 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || Math.abs(nowSeconds - timestamp) > 300) {
return false;
}
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest();
const received = Buffer.from(parts.v1 ?? '', 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}With Express, read the body raw (express.raw({ type: 'application/json' })), pass
req.body.toString('utf8') as rawBody, and answer 401 when the check fails.
Responses and retries
Answer within 10 seconds. CEC Feedback doesn't read the response body.
| Your response | What CEC Feedback does |
|---|---|
2xx | Delivered. |
408, 429, 5xx, a timeout or a network error | Retries. A 429 with Retry-After: <seconds> is retried no sooner than that. |
410 Gone | Stops, and pauses the integration. Turn it back on once the endpoint is back. |
A 3xx | Fails without retrying. Redirects aren't followed, so give the final URL. |
Any other 4xx | Fails without retrying. The integration shows the error. |
Retries back off exponentially: 10 s, 20 s, 40 s, 80 s, 160 s, 320 s, 640 s, then 900 s, so the first attempt plus up to 8 retries over about 36 minutes. After that the delivery is marked failed, and Retry failed in the integration's Settings sends it again.
Duplicates and order
Delivery is at least once: a retry, or a delivery that worked but whose answer was lost, can bring
the same event twice. Deduplicate on X-Feedback-Event-Id (the same as the body's id).
Events can also arrive out of order, so use occurredAt, or fetch the current state from the API,
when order matters.
Allowed URLs
The URL must use https: and point at a public host. CEC Feedback refuses localhost,
*.localhost and *.internal hosts, and IP addresses in private, loopback or link-local ranges,
both when you save the integration and before every send.