Webhook agents
A custom channel turns an event from another system into an agent conversation, then sends the result back to the system that started it. Use one when the built-in HTTP API does not match the provider's payload, conversation key, or reply path.
Author bot/channels/<id>.ts. Its routes mount under
/<slug>/v1/channels/<id>.
Receive a webhook and send the answer back
When a ticket system sends a new message, this endpoint responds with
202 Accepted and an empty body right away. The agent keeps working
after the HTTP request ends, and its completed answer appears as a
comment on the ticket.
// bot/channels/tickets.tsimport { defineChannel, POST } from "@cursor/bdk/channels";import { z } from "zod";import { postTicketComment } from "../lib/tickets.js";type TicketState = { ticketId: string;};export default defineChannel<TicketState>({ state: { ticketId: "" }, routes: [ POST("/ticket", { description: "Answer a new ticket message", bodySchema: z.object({ ticketId: z.string(), message: z.string(), }), handler: async (_request, { send, waitUntil, body }) => { waitUntil( send(body.message, { state: { ticketId: body.ticketId }, title: `Ticket ${body.ticketId}`, }) ); return new Response(null, { status: 202 }); }, }), ], events: { async "message.completed"(event, channel) { if (event.data.finishReason === "tool_call" || event.data.text === "") { return; } await postTicketComment(channel.state.ticketId, event.data.text); }, },});postTicketComment is a small wrapper around the ticket provider's
SDK. Keeping that write in the event handler means the webhook request
does not stay open while the model works.
The caller only ever receives that empty 202. On Cursor-managed
hosting, the deployment sends it before your handler runs and discards
whatever the handler returns, so deliver every result, including
failures, back through the provider's API. See
Route responses.
Keep one conversation per ticket
Without a conversation key, every delivery creates a new session. Add
the ticket ID as the continuationToken so the opening message and all
later comments resume the same conversation, while a different ticket
starts its own.
waitUntil( send(body.message, { continuationToken: `ticket:${body.ticketId}`, state: { ticketId: body.ticketId }, title: `Ticket ${body.ticketId}`, }));The channel owns this key. Callers only need to send the domain ID they already know.
Prepare evidence before the turn
Webhook payloads often contain little more than an object ID. This
handler fetches the full ticket through a deterministic tool and writes
the result to ticket.json, so the model begins with a stable snapshot
instead of spending the turn looking for context.
handler: async (_request, { callTool, send, waitUntil, body }) => { const prepared = await callTool("prepare_ticket", { ticketId: body.ticketId, }); if (prepared.isError) { await postTicketComment( body.ticketId, "I couldn't load this ticket, so I didn't draft a reply." ); return new Response(null, { status: 202 }); } waitUntil( send("Read ticket.json, then draft the reply.", { continuationToken: `ticket:${body.ticketId}`, state: { ticketId: body.ticketId }, workspaceFiles: { "ticket.json": JSON.stringify(prepared.result, null, 2) ?? "null", }, }) ); return new Response(null, { status: 202 });},The same message.completed handler from the first example delivers
the answer back to the ticket. When preparation fails, the handler
reports that on the ticket instead.
Routes receive, events deliver
Routes own inbound HTTP. A route validates the request, decides whether
to start or resume a turn, and acknowledges the caller with 202. The
channel's
events handlers receive session events later, which is where you
deliver the model's answer or report a failure to the originating
system.
Validate payloads with Zod
POST, PUT, and PATCH routes require a Zod bodySchema; GET
requires a querySchema. Validation runs before the handler, and the
parsed value arrives as typed body or query. Use z.unknown() when
the provider controls a variable payload and your own parser will
narrow it.
The route description and schema also appear in GET /v1/info. The
playground uses them to add a Try action and a matching slash
command, so you can exercise the same handler without building a
separate test UI.
Use handler context
The handler context keeps deterministic setup and model work in one route:
-
sendstarts or resumes the agent conversation. -
callToolruns an authored server tool without starting a model turn. -
waitUntilkeeps background work alive after the handler returns. Awaitsendwhen later handler code needs the session handle, such as callingsession.waitForCompletion()before reporting the turn outcome to the provider. -
hostprovides authenticated services for API calls made by channel code.
The full handler API lives in the Channels reference.
Protect public webhooks
Custom routes accept direct loopback requests by default. They reject forwarded requests, so a tunnel or reverse proxy does not silently turn a local development endpoint into a public one.
Before accepting remote traffic, choose an explicit boundary.
bearerAuth() works when the sender can attach an authorization header.
On managed hosting, webhook providers often cannot attach the agent
alias token, so publicEndpoint() lets that custom channel bypass it.
That opens the route to every caller; verify the provider signature
before starting a turn or calling a tool.
import { defineChannel, POST, publicEndpoint } from "@cursor/bdk/channels";import { z } from "zod";import { verifyProviderSignature } from "../lib/tickets.js";export default defineChannel({ auth: [publicEndpoint()], routes: [ POST("/", { description: "Signed ticket webhook", bodySchema: z.unknown(), handler: async request => { const rawBody = await request.text(); const signature = request.headers.get("x-provider-signature"); if (!verifyProviderSignature(rawBody, signature)) { return new Response(null, { status: 202 }); } // Parse and dispatch only after the signature passes. return new Response(null, { status: 202 }); }, }), ],});Because a hosted handler runs after the 202, a delivery with a bad
signature is dropped silently rather than rejected. When the provider
sends a shared secret header or signs the raw body with HMAC-SHA256,
use sharedSecretAuth() or hmacSignatureAuth() instead. Hosting
checks those policies before the handler runs and answers a bad
delivery with 401.
publicEndpoint() only opens this channel's routes. The built-in
session and tool APIs stay private. See
Auth policies for bearer
tokens and the complete policy reference.
Test locally
Run the agent in development mode, then replay the same payload the
provider will send. The route should answer 202 with an empty body
right away, and the resulting session should appear in the playground
while the reply is delivered.
bdk serve --dir . --devcurl -sS -X POST \ http://127.0.0.1:3000/<slug>/v1/channels/tickets/ticket \ -H 'content-type: application/json' \ -d '{"ticketId":"ENG-142","message":"CSV export returns 500"}'Keep representative payloads under fixtures/ and pass them with
--data-binary @fixtures/<event>.json. You can also open the route's
Try action in the playground for manual probes.
Related
- Channels reference: route methods, handler arguments, events, state, and auth policies
- Triage Linear or Jira issues: a complete signed tracker webhook with reply delivery
- HTTP API: built-in session, follow-up, stream, and tool routes
- Deployment: stable hosted URLs and alias-token behavior
- Evals: turn saved payloads into regression fixtures