Event notifications still belong to the previous version of the interface, and in the current one they are marked as coming. In practice that means two things. First: they work and can be used. Second: the new version's resource model should not be designed around the old message format without an intermediate layer. An adapter between an incoming message and your data model is worth writing straight away: it takes a few dozen lines, and without it a format change turns into editing every place the message's fields have spread to.
The set of events is currently narrow: a status change to completion or to error. That is enough for the main scenario - to learn that autonomous work has finished and go collect the result. But building complex state logic on it is unwise: there are few events, and they report a fact rather than details. Everything substantive - what exactly was done, which files were touched, how the checks ended - is fetched with a separate call to the interface, while the notification serves only as a signal that it is time to go for the answer.
The main engineering part here is signature verification. A notification arrives with a signature header, a delivery identifier and an event type. The signature is computed over the raw request body, and the word raw is key: computing it after parsing and re-serializing is not acceptable, because the bytes will change - key order, whitespace, number formatting and unicode escaping all depend on the library. That is the most frequent mistake in implementations, and it does not show up immediately but on the first message with an unusual character, after weeks of everything working.
It helps to see a correct verification once. Below is a function that computes the authentication code over the raw body and compares it with the received value in time that does not depend on the content. An ordinary string comparison is not enough here: it stops at the first difference, and the difference in response time gradually reveals the correct value to anyone willing to send requests for long enough. The sample in the documentation is written for clarity rather than for operation, and that is the usual difference between a demonstration and production code.
The second half of correct handling is deduplication and response speed. Notifications may arrive again, so the delivery identifier is stored and repeats are ignored. And the answer must be fast: a success code is returned immediately while heavy work is done asynchronously. A handler that does all the work before answering will sooner or later hit a timeout and a repeated delivery - and will double the effect if there was no deduplication. The link is direct: a slow response itself creates the duplicates you then have to defend against.
Missing and malformed headers deserve a separate word. They are checked before the body is parsed: if the signature is absent or distorted, the request is rejected without trying to understand what is inside. That simple rule closes a whole class of attempts to feed arbitrary data to a handler under the guise of a notification - the handler's address is open to the network, and anyone can knock on it.
The signing secret leads a life of its own, and it is remembered only at rotation time. It is kept where the service's other secrets are, not in the repository next to the verification code. Changing the secret requires a window in which the handler accepts both the old and the new value: otherwise part of the deliveries sent before the switch will be rejected. The symptom of a failed rotation is unpleasant precisely because of its silence - notifications simply stop arriving, and rejected signatures are usually neither counted nor surfaced in alerts. That is why a counter of rejected signatures is set up at the same time as the verification itself.
There is also a more general limitation: a notification is a hint, not a source of truth. A delivery may not arrive, a handler may be unavailable, an event may be late. A state machine that moves forward only on incoming messages will eventually stall, and a run will stay in an intermediate state forever. A working scheme combines both mechanisms: the notification speeds up the reaction, while periodic polling for runs that have not reported in a while guarantees the state converges even when a delivery is lost.
The engineering conclusion is simple: a notification is untrusted input from the network and must be treated like any other. Signature verification, constant-time comparison, deduplication, a fast response, asynchronous processing, an adapter between versions and fallback polling. Seven items, each written once and then simply working.
The typical failures are predictable. Computing the signature after parsing and re-serializing the body. Comparing strings the ordinary way. Doing heavy work before answering and getting repeated deliveries. Changing the secret without a compatibility window and missing the silence. And tying the data model to the previous version's message format without an adapter.
import crypto from "node:crypto";
// The signature is computed over the RAW body: after parsing the bytes change
export function verifyCursorWebhook(secret, rawBody, signature) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const expectedBytes = Buffer.from(expected);
const receivedBytes = Buffer.from(signature || "");
return expectedBytes.length === receivedBytes.length &&
crypto.timingSafeEqual(expectedBytes, receivedBytes);
}