Integrations
Webhooks
Every comment event sent to your own URL as signed JSON, for Zapier, Make, n8n or your own server.
A webhook posts every comment event to a URL of your own as signed JSON, as it happens. Use it to put new comments in another tool through Zapier, Make or n8n, or to handle them on your own server.
Webhooks come with the Studio and Team plans, and the workspace owner and admins manage them on the Integrations page in the sidebar. Just want new comments in a channel? Slack does that without any setup on your side.
Add a webhook
On the Integrations page, click Add webhook under Webhooks and fill in:
- URL: where layernote posts. It has to start with
https://, use the standard port and be reachable from the internet. Redirects are not followed, so use the final address. - Description: a note for yourself, for example "New comments to our task board".
- Events: which events to send. All of them are on to begin with.
- Projects: all projects, or the ones you choose.
When you save, layernote shows the webhook's signing secret once. Copy it into your receiver right away: you need it to verify the signature. A workspace can have up to 10 webhooks.
Events
| Event | Sent when |
|---|---|
comment.created | A new comment is placed |
comment.replied | Someone replies to a comment |
comment.screenshot_ready | The screenshot of a comment is ready |
comment.resolved | A comment is resolved, also by dragging it to Resolved on the board |
comment.reopened | A resolved comment is opened again |
comment.status_changed | Its status or board column changes |
comment.assigned | Someone is assigned to it |
comment.unassigned | Its assignee is removed |
comment.updated | Its text, priority, category or due date changes |
comment.deleted | A comment or reply is deleted |
Send test on a webhook sends a ping event.
Payload
Every event has the same shape:
{
"id": "evt_9c1f0e5b7a2d4c6e8f0a1b2c3d4e5f60",
"type": "comment.created",
"createdAt": "2026-10-26T09:14:02.511Z",
"workspace": { "id": "ws_1" },
"project": { "id": "p1", "name": "Spring campaign" },
"comment": {
"id": "ann_1",
"parentId": null,
"text": "The bottle looks blue here. It should be the green from the brand guide.",
"status": "OPEN",
"resolved": false,
"boardColumn": "Open",
"priority": "HIGH",
"category": "DESIGN",
"dueDate": null,
"createdAt": "2026-10-26T09:14:01.000Z",
"url": "https://app.layernote.io/projects/p1/live?annotationId=ann_1",
"location": {
"kind": "image",
"name": "hero-banner.png",
"fileVersion": 2,
"pin": { "xPercent": 40, "yPercent": 60 }
},
"screenshot": { "status": "pending", "url": null },
"author": { "name": "Amelia Moreau", "email": "amelia@example.com", "isGuest": false },
"assignee": null,
"labels": ["Copy"],
"source": "dashboard"
},
"actor": { "name": "Amelia Moreau", "email": "amelia@example.com" },
"changed": [],
"previous": null
}
comment.urlopens the comment in layernote. For a reply it opens the comment the reply belongs to.locationsays where the comment is. On a live page, a video or a PDF it also carries the page address, device, element, nearest heading, moment or page number.screenshot.statusispendinguntil the picture is made, thenreadywith aurl;comment.screenshot_readytells you when. A reply has none.changedandprevioussay what changed and what it was before, for example the old status and column oncomment.resolved.author.emailisnullfor a reviewer from a share link.comment.deletedcarries onlyid,parentIdand"deleted": true.
The event describes the comment as it is when it is sent, so a retry carries the latest state.
Verify the signature
layernote signs every request the Standard Webhooks way, with three headers:
| Header | What it holds |
|---|---|
webhook-id | The event's id. The same on every retry, so use it to skip duplicates. |
webhook-timestamp | When it was sent, in seconds since 1970 |
webhook-signature | v1, and a base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body} |
The key is your signing secret without its whsec_ prefix, base64-decoded. Any Standard Webhooks library checks this for you. Or in Node:
import crypto from "node:crypto";
// rawBody: the request body exactly as it arrived, as a string.
function verifyLayernoteWebhook(headers, rawBody, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = String(headers["webhook-signature"] ?? "").split(" ");
// Refuse anything older or newer than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
return signatures.some((entry) => {
const [version, signature] = entry.split(",");
return (
version === "v1" &&
signature?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
});
}
Check the body exactly as it arrived, before parsing it: parsing and writing JSON back out changes the bytes.
Lost the secret, or think someone else has it? Open the webhook and click Make new secret. The old one stops working at once, so update your receiver right after.
Deliveries and retries
- Answer with any
2xxstatus within 10 seconds. Anything else counts as failed. - A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, with the same
webhook-id. - When 5 deliveries in a row fail for good, layernote switches the webhook off and emails the person who added it. Fix the receiver, check it with Send test, then Turn on.
- Recent deliveries on a webhook shows the last 50, with the status code, attempts and error. A failed one has Resend. Deliveries are kept for 30 days.