examples/express/.env.example
# Cursor webhook signing secret CURSOR_WEBHOOK_SECRET=your_webhook_secret_here # Server port (optional) PORT=3000
hookdeck/webhook-skills · GitHub
Receive and verify Cursor Cloud Agent webhooks. Use when setting up Cursor webhook handlers, debugging signature verification, or handling Cloud Agent status change events (ERROR, FINISHED).
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add hookdeck/webhook-skills --skill cursor-webhooks설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
examples/express/.env.example# Cursor webhook signing secret CURSOR_WEBHOOK_SECRET=your_webhook_secret_here # Server port (optional) PORT=3000
examples/express/package.json{
"name": "cursor-webhooks-express",
"version": "1.0.0",
"description": "Cursor webhook handling example for Express",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest"
},
"dependencies": {
"express": "^5.2.1",
"dotenv": "^16.4.7"
},
"devDependencies": {
"jest": "^30.2.0",
"nodemon": "^3.1.9",
"supertest": "^7.0.0"
}
}examples/express/README.md# Cursor Webhooks - Express Example Minimal example of receiving Cursor webhooks with signature verification. ## Prerequisites - Node.js 18+ - Cursor account with webhook signing secret ## Setup 1. Install dependencies: ```bash npm install ``` 2. Copy environment variables: ```bash cp .env.example .env ``` 3. Add your Cursor webhook signing secret to `.env` ## Run ```bash npm start ``` Server runs on http://localhost:3000 ## Test Run the test suite: ```bash npm test ``` ## Local Development Use Hookdeck CLI to receive webhooks locally: ```bash # Forward webhooks to your local server npx hookdeck-cli listen 3000 cursor --path /webhooks/cursor ``` ## Endpoints - `POST /webhooks/cursor` - Receives and verifies Cursor webhooks - `GET /health` - Health check endpoint
examples/express/src/index.jsrequire('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const app = express();
const PORT = process.env.PORT || 3000;
// Verify Cursor webhook signature
function verifyCursorWebhook(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) {
return false;
}
// Cursor sends: sha256=xxxx
const parts = signatureHeader.split('=');
if (parts.length !== 2 || parts[0] !== 'sha256') {
return false;
}
const signature = parts[1];
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
} catch {
return false; // Different lengths
}
}
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// Cursor webhook endpoint
// CRITICAL: Use express.raw() to get the raw body for signature verification
app.post('/webhooks/cursor',
express.raw({ type: 'application/json' }),
(req, res) => {
// Extract headers
const signature = req.headers['x-webhook-signature'];
const webhookId = req.headers['x-webhook-id'];
const event = req.headers['x-webhook-event'];
const userAgent = req.headers['user-agent'];
console.log(`Received webhook: ${event} (ID: ${webhookId})`);
// Verify signature
const secret = process.env.CURSOR_WEBHOOK_SECRET;
if (!verifyCursorWebhook(req.body, signature, secret)) {
console.error('Signature verification failed');
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse the payload after verification
let payload;
try {
payload = JSON.parse(req.body.toString());
} catch (error) {
console.error('Failed to parse payload:', error);
return res.status(400).json({ error: 'Invalid payload' });
}
// Handle the event
if (event === 'statusChange') {
console.log(`Agent ${payload.id} status changed to: ${payload.status}`);
console.log(`Timestamp: ${payload.timestamp}`);
if (payload.source) {
console.log(`Repository: ${payload.source.repository}`);
console.log(`Ref: ${payload.source.ref}`);
}
if (payload.target) {
console.log(`Target URL: ${payload.target.url}`);
console.log(`Branch: ${payload.target.branchName}`);
if (payload.target.prUrl) {
console.log(`PR URL: ${payload.target.prUrl}`);
}
}
if (payload.status === 'FINISHED') {
console.log(`Summary: ${payload.summary}`);
// Handle successful completion
// e.g., update database, notify users, trigger CI/CD
} else if (payload.status === 'ERROR') {
console.error(`Agent error for ${payload.id}`);
// Handle error case
// e.g., send alerts, retry logic
}
}
// Always respond quickly to webhooks
res.json({ received: true });
}
);
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Webhook error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// Start server only if not in test environment
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => {
console.log(`Cursor webhook server running on port ${PORT}`);
if (!process.env.CURSOR_WEBHOOK_SECRET) {
console.warn('WARNING: CURSOR_WEBHOOK_SECRET not set. Webhooks will fail verification.');
}
});
}
// Export app for testing
module.exports = app;examples/express/test/webhook.test.jsconst request = require('supertest');
const crypto = require('crypto');
// Mock environment variables
process.env.NODE_ENV = 'test';
process.env.CURSOR_WEBHOOK_SECRET = 'test_secret_key';
const app = require('../src/index.js');
// Helper to generate valid Cursor webhook signature
function generateSignature(payload, secret) {
const signature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return `sha256=${signature}`;
}
describe('Cursor Webhook Handler', () => {
const validPayload = {
event: 'statusChange',
timestamp: '2024-01-01T12:00:00.000Z',
id: 'agent_123456',
status: 'FINISHED',
source: {
repository: 'https://github.com/test/repo',
ref: 'main'
},
target: {
url: 'https://github.com/test/repo/pull/123',
branchName: 'feature-branch',
prUrl: 'https://github.com/test/repo/pull/123'
},
summary: 'Updated 3 files and fixed linting errors'
};
test('accepts valid webhook with correct signature', async () => {
const payload = JSON.stringify(validPayload);
const signature = generateSignature(payload, process.env.CURSOR_WEBHOOK_SECRET);
const response = await request(app)
.post('/webhooks/cursor')
.set('Content-Type', 'application/json')
.set('X-Webhook-Signature', signature)
.set('X-Webhook-ID', 'msg_123456')
.set('X-Webhook-Event', 'statusChange')
.set('User-Agent', 'Cursor-Agent-Webhook/1.0')
.send(payload);
expect(response.status).toBe(200);
expect(response.body).toEqual({ received: true });
});
test('rejects webhook with invalid signature', async () => {
const payload = JSON.stringify(validPayload);
const invalidSignature = 'sha256=invalid_signature';
const response = await request(app)
.post('/webhooks/cursor')
.set('Content-Type', 'application/json')
.set('X-Webhook-Signature', invalidSignature)
.set('X-Webhook-ID', 'msg_123456')
.set('X-Webhook-Event', 'statusChange')
.send(payload);
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'Invalid signature' });
});
test('rejects webhook with missing signature', async () => {
const payload = JSON.stringify(validPayload);
const response = await request(app)
.post('/webhooks/cursor')
.set('Content-Type', 'application/json')
.set('X-Webhook-ID', 'msg_123456')
.set('X-Webhook-Event', 'statusChange')
.send(payload);
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'Invalid signature' });
});
test('rejects webhook with wrong signature format', async () => {
const payload = JSON.stringify(validPayload);
const wrongFormatSignature = 'invalid_format_signature';
const response = await request(app)
.post('/webhooks/cursor')
.set('Content-Type', 'application/json')
.set('X-Webhook-Signature', wrongFormatSignature)
.set('X-Webhook-ID', 'msg_123456')
.set('X-Webhook-Event', 'statusChange')
.send(payload);
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'Invalid signature' });
});
test('handles ERROR status', async () => {
const errorPayload = {
...validPayload,
status: 'ERROR'
};
const payload = JSON.stringify(errorPayload);
const signature = generateSignature(payload, process.env.CURSOR_WEBHOOK_SECRET);
const response = await request(app)
.post('/webhooks/cursor')
.set('Content-Type', 'application/json')
.set('X-Webhook-Signature', signature)
.set('X-Webhook-ID', 'msg_123456')
.set('X-Webhook-Event', 'statusChange')
.send(payload);
expect(response.status).toBe(200);
expect(response.body).toEqual({ received: true });
});
test('rejects invalid JSON payload', async () => {
const invalidJson = '{"invalid": json}';
const signature = generateSignature(invalidJson, process.env.CURSOR_WEBHOOK_SECRET);
const response = await request(app)
.post('/webhooks/cursor')
.set('Content-Type', 'application/json')
.set('X-Webhook-Signature', signature)
.set('X-Webhook-ID', 'msg_123456')
.set('X-Webhook-Event', 'statusChange')
.send(invalidJson);
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Invalid payload' });
});
test('health check returns ok', async () => {
const response = await request(app)
.get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'ok' });
});
});examples/fastapi/.env.example# Cursor webhook signing secret CURSOR_WEBHOOK_SECRET=your_webhook_secret_here # Server configuration (optional) HOST=0.0.0.0 PORT=8000
examples/fastapi/main.pyimport os
import hmac
import hashlib
import json
from typing import Optional
from fastapi import FastAPI, Request, HTTPException, Header
from fastapi.responses import JSONResponse
from dotenv import load_dotenv
import logging
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
def verify_cursor_webhook(body: bytes, signature_header: str, secret: str) -> bool:
"""Verify Cursor webhook signature."""
if not signature_header or not secret:
return False
# Cursor sends: sha256=xxxx
parts = signature_header.split('=')
if len(parts) != 2 or parts[0] != 'sha256':
return False
signature = parts[1]
expected = hmac.new(
secret.encode(),
body,
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(signature, expected)
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "ok"}
@app.post("/webhooks/cursor")
async def handle_cursor_webhook(
request: Request,
x_webhook_signature: Optional[str] = Header(None),
x_webhook_id: Optional[str] = Header(None),
x_webhook_event: Optional[str] = Header(None),
user_agent: Optional[str] = Header(None)
):
"""Handle Cursor webhook."""
logger.info(f"Received webhook: {x_webhook_event} (ID: {x_webhook_id})")
# Get raw body for signature verification
body = await request.body()
# Verify signature
secret = os.getenv('CURSOR_WEBHOOK_SECRET')
if not secret:
logger.error("CURSOR_WEBHOOK_SECRET not configured")
raise HTTPException(status_code=500, detail="Server configuration error")
if not verify_cursor_webhook(body, x_webhook_signature, secret):
logger.error("Signature verification failed")
raise HTTPException(status_code=401, detail="Invalid signature")
# Parse the payload after verification
try:
payload = json.loads(body.decode())
except json.JSONDecodeError as e:
logger.error(f"Failed to parse payload: {e}")
raise HTTPException(status_code=400, detail="Invalid payload")
# Handle the event
if x_webhook_event == 'statusChange':
agent_id = payload.get('id')
status = payload.get('status')
timestamp = payload.get('timestamp')
logger.info(f"Agent {agent_id} status changed to: {status}")
logger.info(f"Timestamp: {timestamp}")
if 'source' in payload:
logger.info(f"Repository: {payload['source'].get('repository')}")
logger.info(f"Ref: {payload['source'].get('ref')}")
if 'target' in payload:
logger.info(f"Target URL: {payload['target'].get('url')}")
logger.info(f"Branch: {payload['target'].get('branchName')}")
if 'prUrl' in payload['target']:
logger.info(f"PR URL: {payload['target']['prUrl']}")
if status == 'FINISHED':
logger.info(f"Summary: {payload.get('summary')}")
# Handle successful completion
# e.g., update database, notify users, trigger CI/CD
elif status == 'ERROR':
logger.error(f"Agent error for {agent_id}")
# Handle error case
# e.g., send alerts, retry logic
# Always respond quickly to webhooks
return JSONResponse(content={"received": True})
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Handle unexpected errors."""
logger.error(f"Webhook error: {exc}")
return JSONResponse(
status_code=500,
content={"error": "Internal server error"}
)
if __name__ == "__main__":
import uvicorn
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", "8000"))
if not os.getenv("CURSOR_WEBHOOK_SECRET"):
logger.warning("WARNING: CURSOR_WEBHOOK_SECRET not set. Webhooks will fail verification.")
uvicorn.run(app, host=host, port=port)examples/fastapi/README.md# Cursor Webhooks - FastAPI Example Minimal example of receiving Cursor webhooks with signature verification in FastAPI. ## Prerequisites - Python 3.9+ - Cursor account with webhook signing secret ## Setup 1. Create a virtual environment: ```bash python3 -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate ``` 2. Install dependencies: ```bash pip install -r requirements.txt ``` 3. Copy environment variables: ```bash cp .env.example .env ``` 4. Add your Cursor webhook signing secret to `.env` ## Run ```bash uvicorn main:app --reload ``` Server runs on http://localhost:8000 API documentation available at http://localhost:8000/docs ## Test Run the test suite: ```bash pytest test_webhook.py ``` ## Local Development Use Hookdeck CLI to receive webhooks locally: ```bash # Forward webhooks to your local server npx hookdeck-cli listen 8000 cursor --path /webhooks/cursor ``` ## Endpoints - `POST /webhooks/cursor` - Receives and verifies Cursor webhooks - `GET /health` - Health check endpoint
examples/fastapi/requirements.txtfastapi>=0.128.0 uvicorn[standard]>=0.34.0 python-dotenv>=1.0.0 pytest>=9.0.2 httpx>=0.28.1
examples/fastapi/test_webhook.pyimport os
import json
import hmac
import hashlib
import pytest
from fastapi.testclient import TestClient
# Set test environment variable
os.environ['CURSOR_WEBHOOK_SECRET'] = 'test_secret_key'
from main import app
client = TestClient(app)
def generate_signature(payload: bytes, secret: str) -> str:
"""Generate valid Cursor webhook signature."""
signature = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return f"sha256={signature}"
class TestCursorWebhook:
"""Test Cursor webhook handler."""
def setup_method(self):
"""Set up test data."""
self.valid_payload = {
"event": "statusChange",
"timestamp": "2024-01-01T12:00:00.000Z",
"id": "agent_123456",
"status": "FINISHED",
"source": {
"repository": "https://github.com/test/repo",
"ref": "main"
},
"target": {
"url": "https://github.com/test/repo/pull/123",
"branchName": "feature-branch",
"prUrl": "https://github.com/test/repo/pull/123"
},
"summary": "Updated 3 files and fixed linting errors"
}
def test_valid_webhook(self):
"""Test accepting valid webhook with correct signature."""
payload = json.dumps(self.valid_payload).encode()
signature = generate_signature(payload, os.environ['CURSOR_WEBHOOK_SECRET'])
response = client.post(
"/webhooks/cursor",
content=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-ID": "msg_123456",
"X-Webhook-Event": "statusChange",
"User-Agent": "Cursor-Agent-Webhook/1.0"
}
)
assert response.status_code == 200
assert response.json() == {"received": True}
def test_invalid_signature(self):
"""Test rejecting webhook with invalid signature."""
payload = json.dumps(self.valid_payload).encode()
response = client.post(
"/webhooks/cursor",
content=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": "sha256=invalid_signature",
"X-Webhook-ID": "msg_123456",
"X-Webhook-Event": "statusChange"
}
)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid signature"}
def test_missing_signature(self):
"""Test rejecting webhook with missing signature."""
payload = json.dumps(self.valid_payload).encode()
response = client.post(
"/webhooks/cursor",
content=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-ID": "msg_123456",
"X-Webhook-Event": "statusChange"
}
)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid signature"}
def test_wrong_signature_format(self):
"""Test rejecting webhook with wrong signature format."""
payload = json.dumps(self.valid_payload).encode()
response = client.post(
"/webhooks/cursor",
content=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": "invalid_format_signature",
"X-Webhook-ID": "msg_123456",
"X-Webhook-Event": "statusChange"
}
)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid signature"}
def test_error_status(self):
"""Test handling ERROR status."""
error_payload = self.valid_payload.copy()
error_payload["status"] = "ERROR"
payload = json.dumps(error_payload).encode()
signature = generate_signature(payload, os.environ['CURSOR_WEBHOOK_SECRET'])
response = client.post(
"/webhooks/cursor",
content=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-ID": "msg_123456",
"X-Webhook-Event": "statusChange"
}
)
assert response.status_code == 200
assert response.json() == {"received": True}
def test_invalid_json(self):
"""Test rejecting invalid JSON payload."""
invalid_json = b'{"invalid": json}'
signature = generate_signature(invalid_json, os.environ['CURSOR_WEBHOOK_SECRET'])
response = client.post(
"/webhooks/cursor",
content=invalid_json,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-ID": "msg_123456",
"X-Webhook-Event": "statusChange"
}
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid payload"}
def test_health_check(self):
"""Test health check endpoint."""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_missing_env_variable(self):
"""Test handling missing environment variable."""
# Temporarily remove the secret
original_secret = os.environ.get('CURSOR_WEBHOOK_SECRET')
del os.environ['CURSOR_WEBHOOK_SECRET']
payload = json.dumps(self.valid_payload).encode()
response = client.post(
"/webhooks/cursor",
content=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": "sha256=any",
"X-Webhook-ID": "msg_123456",
"X-Webhook-Event": "statusChange"
}
)
assert response.status_code == 500
assert response.json() == {"detail": "Server configuration error"}
# Restore the secret
if original_secret:
os.environ['CURSOR_WEBHOOK_SECRET'] = original_secretexamples/nextjs/.env.example# Cursor webhook signing secret CURSOR_WEBHOOK_SECRET=your_webhook_secret_here
examples/nextjs/app/webhooks/cursor/route.tsimport { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
// Verify Cursor webhook signature
function verifyCursorWebhook(
rawBody: Buffer,
signatureHeader: string | null,
secret: string
): boolean {
if (!signatureHeader || !secret) {
return false;
}
// Cursor sends: sha256=xxxx
const parts = signatureHeader.split('=');
if (parts.length !== 2 || parts[0] !== 'sha256') {
return false;
}
const signature = parts[1];
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
} catch {
return false; // Different lengths
}
}
export async function POST(request: NextRequest) {
try {
// Get raw body
const rawBody = await request.arrayBuffer();
const body = Buffer.from(rawBody);
// Extract headers
const signature = request.headers.get('x-webhook-signature');
const webhookId = request.headers.get('x-webhook-id');
const event = request.headers.get('x-webhook-event');
const userAgent = request.headers.get('user-agent');
console.log(`Received webhook: ${event} (ID: ${webhookId})`);
// Verify signature
const secret = process.env.CURSOR_WEBHOOK_SECRET;
if (!secret) {
console.error('CURSOR_WEBHOOK_SECRET not configured');
return NextResponse.json(
{ error: 'Server configuration error' },
{ status: 500 }
);
}
if (!verifyCursorWebhook(body, signature, secret)) {
console.error('Signature verification failed');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// Parse the payload after verification
let payload: any;
try {
payload = JSON.parse(body.toString());
} catch (error) {
console.error('Failed to parse payload:', error);
return NextResponse.json(
{ error: 'Invalid payload' },
{ status: 400 }
);
}
// Handle the event
if (event === 'statusChange') {
console.log(`Agent ${payload.id} status changed to: ${payload.status}`);
console.log(`Timestamp: ${payload.timestamp}`);
if (payload.source) {
console.log(`Repository: ${payload.source.repository}`);
console.log(`Ref: ${payload.source.ref}`);
}
if (payload.target) {
console.log(`Target URL: ${payload.target.url}`);
console.log(`Branch: ${payload.target.branchName}`);
if (payload.target.prUrl) {
console.log(`PR URL: ${payload.target.prUrl}`);
}
}
if (payload.status === 'FINISHED') {
console.log(`Summary: ${payload.summary}`);
// Handle successful completion
// e.g., update database, notify users, trigger CI/CD
} else if (payload.status === 'ERROR') {
console.error(`Agent error for ${payload.id}`);
// Handle error case
// e.g., send alerts, retry logic
}
}
// Always respond quickly to webhooks
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}examples/nextjs/package.json{
"name": "cursor-webhooks-nextjs",
"version": "1.0.0",
"description": "Cursor webhook handling example for Next.js",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"next": "^16.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^20.11.19",
"@types/react": "^19.0.3",
"@types/react-dom": "^19.0.2",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}examples/nextjs/README.md# Cursor Webhooks - Next.js Example Minimal example of receiving Cursor webhooks with signature verification in Next.js App Router. ## Prerequisites - Node.js 18+ - Cursor account with webhook signing secret ## Setup 1. Install dependencies: ```bash npm install ``` 2. Copy environment variables: ```bash cp .env.example .env.local ``` 3. Add your Cursor webhook signing secret to `.env.local` ## Run Development mode: ```bash npm run dev ``` Production mode: ```bash npm run build npm start ``` Server runs on http://localhost:3000 ## Test Run the test suite: ```bash npm test ``` ## Local Development Use Hookdeck CLI to receive webhooks locally: ```bash # Forward webhooks to your local server npx hookdeck-cli listen 3000 cursor --path /webhooks/cursor ``` ## API Routes - `POST /webhooks/cursor` - Receives and verifies Cursor webhooks
examples/nextjs/test/webhook.test.tsimport { describe, test, expect, beforeAll } from 'vitest';
import crypto from 'crypto';
import { POST } from '../app/webhooks/cursor/route';
import { NextRequest } from 'next/server';
// Mock environment variables
process.env.CURSOR_WEBHOOK_SECRET = 'test_secret_key';
// Helper to generate valid Cursor webhook signature
function generateSignature(payload: string, secret: string): string {
const signature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return `sha256=${signature}`;
}
// Helper to create NextRequest with proper headers
function createRequest(
payload: string,
headers: Record<string, string>
): NextRequest {
const url = 'http://localhost:3000/webhooks/cursor';
return new NextRequest(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: payload
});
}
describe('Cursor Webhook Handler', () => {
const validPayload = {
event: 'statusChange',
timestamp: '2024-01-01T12:00:00.000Z',
id: 'agent_123456',
status: 'FINISHED',
source: {
repository: 'https://github.com/test/repo',
ref: 'main'
},
target: {
url: 'https://github.com/test/repo/pull/123',
branchName: 'feature-branch',
prUrl: 'https://github.com/test/repo/pull/123'
},
summary: 'Updated 3 files and fixed linting errors'
};
test('accepts valid webhook with correct signature', async () => {
const payload = JSON.stringify(validPayload);
const signature = generateSignature(payload, process.env.CURSOR_WEBHOOK_SECRET!);
const request = createRequest(payload, {
'X-Webhook-Signature': signature,
'X-Webhook-ID': 'msg_123456',
'X-Webhook-Event': 'statusChange',
'User-Agent': 'Cursor-Agent-Webhook/1.0'
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ received: true });
});
test('rejects webhook with invalid signature', async () => {
const payload = JSON.stringify(validPayload);
const invalidSignature = 'sha256=invalid_signature';
const request = createRequest(payload, {
'X-Webhook-Signature': invalidSignature,
'X-Webhook-ID': 'msg_123456',
'X-Webhook-Event': 'statusChange'
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data).toEqual({ error: 'Invalid signature' });
});
test('rejects webhook with missing signature', async () => {
const payload = JSON.stringify(validPayload);
const request = createRequest(payload, {
'X-Webhook-ID': 'msg_123456',
'X-Webhook-Event': 'statusChange'
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data).toEqual({ error: 'Invalid signature' });
});
test('rejects webhook with wrong signature format', async () => {
const payload = JSON.stringify(validPayload);
const wrongFormatSignature = 'invalid_format_signature';
const request = createRequest(payload, {
'X-Webhook-Signature': wrongFormatSignature,
'X-Webhook-ID': 'msg_123456',
'X-Webhook-Event': 'statusChange'
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data).toEqual({ error: 'Invalid signature' });
});
test('handles ERROR status', async () => {
const errorPayload = {
...validPayload,
status: 'ERROR'
};
const payload = JSON.stringify(errorPayload);
const signature = generateSignature(payload, process.env.CURSOR_WEBHOOK_SECRET!);
const request = createRequest(payload, {
'X-Webhook-Signature': signature,
'X-Webhook-ID': 'msg_123456',
'X-Webhook-Event': 'statusChange'
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ received: true });
});
test('rejects invalid JSON payload', async () => {
const invalidJson = '{"invalid": json}';
const signature = generateSignature(invalidJson, process.env.CURSOR_WEBHOOK_SECRET!);
const request = createRequest(invalidJson, {
'X-Webhook-Signature': signature,
'X-Webhook-ID': 'msg_123456',
'X-Webhook-Event': 'statusChange'
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data).toEqual({ error: 'Invalid payload' });
});
test('handles missing environment variable', async () => {
// Temporarily remove the secret
const originalSecret = process.env.CURSOR_WEBHOOK_SECRET;
delete process.env.CURSOR_WEBHOOK_SECRET;
const payload = JSON.stringify(validPayload);
const request = createRequest(payload, {
'X-Webhook-Signature': 'sha256=any',
'X-Webhook-ID': 'msg_123456',
'X-Webhook-Event': 'statusChange'
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data).toEqual({ error: 'Server configuration error' });
// Restore the secret
process.env.CURSOR_WEBHOOK_SECRET = originalSecret;
});
});examples/nextjs/vitest.config.tsimport { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node'
}
});references/overview.md# Cursor Webhooks Overview
## What Are Cursor Webhooks?
Cursor Cloud Agent webhooks are HTTP callbacks that notify your application when agent status changes occur. These webhooks enable real-time monitoring of Cloud Agent operations, allowing you to track when agents complete tasks or encounter errors.
## Common Event Types
| Event | Triggered When | Common Use Cases |
|-------|----------------|------------------|
| `statusChange` | Agent status changes to ERROR or FINISHED | Monitor agent completion, handle errors, update UI |
## Event Payload Structure
All Cursor webhooks share a consistent payload structure:
```json
{
"event": "statusChange",
"timestamp": "2024-01-01T12:00:00.000Z",
"id": "agent_123456",
"status": "FINISHED",
"source": {
"repository": "https://github.com/user/repo",
"ref": "main"
},
"target": {
"url": "https://github.com/user/repo/pull/123",
"branchName": "feature-branch",
"prUrl": "https://github.com/user/repo/pull/123"
},
"summary": "Updated 3 files and fixed linting errors"
}
```
### Status Values
- `FINISHED` - Agent completed successfully
- `ERROR` - Agent encountered an error
## HTTP Headers
Cursor sends these headers with every webhook:
| Header | Description | Example |
|--------|-------------|---------|
| `X-Webhook-Signature` | HMAC-SHA256 signature | `sha256=abc123...` |
| `X-Webhook-ID` | Unique delivery ID | `msg_01234567890` |
| `X-Webhook-Event` | Event type | `statusChange` |
| `User-Agent` | Identifies Cursor webhooks | `Cursor-Agent-Webhook/1.0` |
| `Content-Type` | Payload format | `application/json` |
## Full Event Reference
For the complete list of events and detailed specifications, see [Cursor's webhook documentation](https://cursor.com/docs/cloud-agent/api/webhooks).references/setup.md# Setting Up Cursor Webhooks ## Prerequisites - Cursor Cloud Agent access - Your application's webhook endpoint URL - Admin access to configure webhooks in your Cursor settings ## Get Your Signing Secret 1. Log in to your Cursor dashboard 2. Navigate to Cloud Agent settings 3. Go to the Webhooks section 4. Copy your webhook signing secret - Keep this secret secure - Never commit it to version control - Rotate it periodically for security ## Register Your Endpoint 1. In the Cursor Cloud Agent settings, click "Add Webhook" 2. Enter your webhook endpoint URL: - Production: `https://yourdomain.com/webhooks/cursor` - Development: Use Hookdeck CLI tunnel URL 3. Select the events to receive: - `statusChange` - Notifies when agent status changes 4. Save the webhook configuration ## Test Your Webhook 1. Cursor will send a test `statusChange` event to verify your endpoint 2. Your endpoint should: - Return a 200 status code - Verify the signature - Process the test payload ## Environment Configuration Add your signing secret to your environment: ```bash # .env file CURSOR_WEBHOOK_SECRET=your_webhook_secret_here ``` ## Security Best Practices - Always verify webhook signatures - Use HTTPS endpoints only - Store secrets in environment variables - Implement request timeouts - Log webhook events for debugging - Return 200 quickly, process asynchronously if needed
references/verification.md# Cursor Signature Verification
## How It Works
Cursor uses HMAC-SHA256 to sign webhook payloads. The signature is sent in the `X-Webhook-Signature` header with the format `sha256=<hex_digest>`.
The signature is computed by:
1. Taking the raw request body (before parsing)
2. Creating an HMAC-SHA256 hash using your webhook secret
3. Encoding the result as hexadecimal
4. Prefixing with `sha256=`
## Implementation
### Manual Verification (Recommended)
```javascript
const crypto = require('crypto');
function verifyCursorWebhook(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) {
return false;
}
// Extract algorithm and signature
const [algorithm, signature] = signatureHeader.split('=');
if (algorithm !== 'sha256') {
return false;
}
// Calculate expected signature
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
} catch {
return false; // Different lengths
}
}
```
### Python Implementation
```python
import hmac
import hashlib
def verify_cursor_webhook(body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header or not secret:
return False
# Extract algorithm and signature
parts = signature_header.split('=')
if len(parts) != 2 or parts[0] != 'sha256':
return False
signature = parts[1]
# Calculate expected signature
expected = hmac.new(
secret.encode(),
body,
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(signature, expected)
```
## Common Gotchas
### 1. Raw Body Parsing
**Problem**: Using parsed JSON instead of raw body breaks signature verification.
**Solution**: Always use the raw request body:
```javascript
// Express
app.use('/webhooks/cursor', express.raw({ type: 'application/json' }));
// Next.js
export const config = { api: { bodyParser: false } };
// FastAPI
body = await request.body() # Get raw bytes
```
### 2. Header Case Sensitivity
**Problem**: Some frameworks lowercase headers.
**Solution**: Access headers case-insensitively:
```javascript
// Express normalizes to lowercase
const signature = req.headers['x-webhook-signature'];
// FastAPI preserves case
signature = request.headers.get('X-Webhook-Signature')
```
### 3. Missing Timing-Safe Comparison
**Problem**: Using `===` for comparison is vulnerable to timing attacks.
**Solution**: Always use timing-safe comparison:
```javascript
// Good
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
// Bad
signature === expected
```
### 4. Incorrect Secret Format
**Problem**: Using the wrong secret or format.
**Solution**: Use the exact secret from Cursor dashboard, no modifications.
## Debugging Verification Failures
If signature verification fails:
1. **Check the raw body**: Log the exact bytes being signed
2. **Verify the secret**: Ensure no extra whitespace or encoding issues
3. **Check headers**: Log the exact signature header value
4. **Compare signatures**: Log both calculated and received signatures (in development only)
### Debug Helper
```javascript
function debugWebhook(rawBody, signatureHeader, secret) {
console.log('=== Webhook Debug ===');
console.log('Body length:', rawBody.length);
console.log('Body preview:', rawBody.toString().substring(0, 100));
console.log('Signature header:', signatureHeader);
const [algorithm, signature] = signatureHeader.split('=');
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
console.log('Received sig:', signature);
console.log('Expected sig:', expected);
console.log('Match:', signature === expected);
console.log('===================');
}
```
**Important**: Only use debug logging in development. Never log signatures or secrets in production.SKILL.md---
name: cursor-webhooks
description: >
Receive and verify Cursor Cloud Agent webhooks. Use when setting up Cursor
webhook handlers, debugging signature verification, or handling Cloud Agent
status change events (ERROR, FINISHED).
license: MIT
metadata:
author: hookdeck
version: "0.1.0"
repository: https://github.com/hookdeck/webhook-skills
---
# Cursor Webhooks
## When to Use This Skill
- Setting up Cursor Cloud Agent webhook handlers
- Debugging signature verification failures
- Understanding Cursor webhook event types and payloads
- Handling Cloud Agent status change events (ERROR, FINISHED)
## Essential Code (USE THIS)
### Cursor Signature Verification (JavaScript)
```javascript
const crypto = require('crypto');
function verifyCursorWebhook(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
// Cursor sends: sha256=xxxx
const [algorithm, signature] = signatureHeader.split('=');
if (algorithm !== 'sha256') return false;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
```
### Express Webhook Handler
```javascript
const express = require('express');
const app = express();
// CRITICAL: Use express.raw() - Cursor requires raw body for signature verification
app.post('/webhooks/cursor',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-webhook-signature'];
const webhookId = req.headers['x-webhook-id'];
const event = req.headers['x-webhook-event'];
// Verify signature
if (!verifyCursorWebhook(req.body, signature, process.env.CURSOR_WEBHOOK_SECRET)) {
console.error('Cursor signature verification failed');
return res.status(401).send('Invalid signature');
}
// Parse payload after verification
const payload = JSON.parse(req.body.toString());
console.log(`Received ${event} (id: ${webhookId})`);
// Handle status changes
if (event === 'statusChange') {
console.log(`Agent ${payload.id} status: ${payload.status}`);
if (payload.status === 'FINISHED') {
console.log(`Summary: ${payload.summary}`);
} else if (payload.status === 'ERROR') {
console.error(`Agent error for ${payload.id}`);
}
}
res.json({ received: true });
}
);
```
### Python Signature Verification (FastAPI)
```python
import hmac
import hashlib
from fastapi import Request, HTTPException
def verify_cursor_webhook(body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header or not secret:
return False
# Cursor sends: sha256=xxxx
parts = signature_header.split('=')
if len(parts) != 2 or parts[0] != 'sha256':
return False
signature = parts[1]
expected = hmac.new(
secret.encode(),
body,
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(signature, expected)
```
## Common Event Types
| Event Type | Description | Common Use Cases |
|------------|-------------|------------------|
| `statusChange` | Agent status changed | Monitor agent completion, handle errors |
### Event Payload Structure
```json
{
"event": "statusChange",
"timestamp": "2024-01-01T12:00:00.000Z",
"id": "agent_123456",
"status": "FINISHED", // or "ERROR"
"source": {
"repository": "https://github.com/user/repo",
"ref": "main"
},
"target": {
"url": "https://github.com/user/repo/pull/123",
"branchName": "feature-branch",
"prUrl": "https://github.com/user/repo/pull/123"
},
"summary": "Updated 3 files and fixed linting errors"
}
```
## Environment Variables
```bash
# Your Cursor webhook signing secret
CURSOR_WEBHOOK_SECRET=your_webhook_secret_here
```
## Local Development
For local webhook testing, install Hookdeck CLI:
```bash
```
Then start the tunnel:
```bash
npx hookdeck-cli listen 3000 cursor --path /webhooks/cursor
```
No account required. Provides local tunnel + web UI for inspecting requests.
## Resources
- `overview.md` - What Cursor webhooks are, event types
- `setup.md` - Configure webhooks in Cursor dashboard
- `verification.md` - Signature verification details and gotchas
- `examples/` - Runnable examples per framework
## Recommended: webhook-handler-patterns
For production-ready webhook handling, also use the webhook-handler-patterns skill:
- [Handler sequence](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/handler-sequence.md)
- [Idempotency](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/idempotency.md)
- [Error handling](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/error-handling.md)
- [Retry logic](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/retry-logic.md)
## Related Skills
- [stripe-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/stripe-webhooks) - Stripe webhook handling
- [github-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/github-webhooks) - GitHub webhook handling
- [shopify-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/shopify-webhooks) - Shopify webhook handling
- [openai-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/openai-webhooks) - OpenAI webhook handling
- [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) - Idempotency, error handling, retry logic
- [hookdeck-event-gateway](https://github.com/hookdeck/webhook-skills/tree/main/skills/hookdeck-event-gateway) - Webhook infrastructure that replaces your queue — guaranteed delivery, automatic retries, replay, rate limiting, and observability for your webhook handlers