Skip to main content
A webhook trigger runs a workflow or agent when an external system POSTs a request to its endpoint. It’s the primary way to react to events from other services: signups, payments, CI events, and the like.

Example requests

Ask your coding agent which external event should kick off a run. It can create the endpoint and validate the payload.
“When Stripe sends a payment succeeded webhook, update the customer record and start onboarding.”
“When we get a new GitHub issue, triage it and add the right labels.”
“When our app POSTs a new signup, enrich the profile and notify the sales channel.”

Define a webhook

Use defineWebhookSource with a slug, an endpoint, and a request schema, then attach a target.
src/triggers/signup.ts
import { defineWebhookSource } from "@keystrokehq/keystroke/trigger";
import { z } from "zod";
import workflow from "../workflows/signup-pipeline";

export default defineWebhookSource({
  slug: "signup",
  endpoint: "signup",
  request: z.object({
    name: z.string().trim().min(1),
    email: z.string().email(),
  }),
}).attach({ workflow });
OptionRequiredWhat it does
slugYesStable trigger slug (used in the attachment id and run history)
endpointYesThe route suffix; the webhook URL is POST /triggers/{endpoint}
requestYesZod schema the incoming body must match for the trigger to fire
filterNoExtra Zod constraints beyond request. See filtering.

The webhook URL

The endpoint becomes the route POST /triggers/{endpoint}. Print the full URL for a deployed trigger:
keystroke triggers url signup
Send a test request to it during development:
curl -X POST http://localhost:3002/triggers/signup \
  -H "Content-Type: application/json" \
  -d '{ "name": "Ada", "email": "ada@example.com" }'
When the body matches request, the trigger fires and starts a run. When it doesn’t, the request is rejected (or skipped, on a shared endpoint). Webhooks ack asynchronously: the POST returns immediately — 202 with the runId when a trigger matches, or { ok: true, skipped: true } when none does — and the run executes in the background. The workflow’s output is not returned in the webhook response (there’s no “respond to webhook”). To return data to the caller, make an outbound call from the workflow, or have the caller poll the run via run history or the runs API.

Authenticating webhooks

Under keystroke dev the local webhook endpoints are open, so the curl above works with no auth. On the deployed platform, each webhook route requires a webhook API key. keystroke triggers url returns the full URL with that key already included as a ?token= query parameter, so you can hand it straight to the sending system:
keystroke triggers url signup
If the sender prefers a header to the query parameter, pass the same key one of these ways:
FormExample
Query?token=<key> or ?api_key=<key>
HeaderAuthorization: Bearer <key>
Headerx-api-key: <key>
A request with a missing or invalid key is rejected with 401.

Validation

The request schema is both the contract and the gate: only requests that parse against it fire the trigger. Model just the fields you care about; Zod ignores extra fields by default, so you don’t have to describe an entire third-party payload. For constraints beyond shape (for example, “only invoice.paid events”), add an optional filter.

Exportable schemas

Webhook request and filter schemas are exported to JSON Schema at build time for platform filtering, the canvas, and run forms. They must be plain structural Zod — objects, strings, literals, unions, and built-in validators like .email() or .min(). Do not use code-based methods like .transform(), .preprocess(), .refine(), or .superRefine(); keystroke build will fail with an error explaining the fix. When you need to remap or normalize fields (for example, coalescing two payload keys into one), keep the exported schema as the wire shape and remap in .attach({ transform }) or with a plain function at the top of run(). See advanced triggers — transform a workflow input.
const WebhookRequest = z.object({
  landing_page_url: z.string().nullable().optional(),
  bootcamp_landing_page: z.string().nullable().optional(),
  email: z.string().email(),
});

export default defineWebhookSource({
  slug: "bootcamp-ae",
  endpoint: "bootcamp-ae",
  request: WebhookRequest,
}).attach({
  workflow,
  transform: (payload) => ({
    email: payload.email,
    landing_page_url: payload.landing_page_url ?? payload.bootcamp_landing_page ?? null,
  }),
});
The same rule applies to workflow input and output schemas when they are exported at build time.

Filtering

A filter is a second Zod schema applied after request. Use it to narrow which payloads fire the trigger without changing the request contract:
export default defineWebhookSource({
  slug: "stripe-invoice-paid",
  endpoint: "stripe",
  request: z.object({ type: z.string(), data: z.object({ id: z.string() }) }),
  filter: z.object({ type: z.literal("invoice.paid") }),
}).attach({
  workflow,
  transform: (payload) => ({ invoiceId: (payload as { data: { id: string } }).data.id }),
});
Filtering lives on the source, while transform shapes the input for the run. See advanced triggers for both.

Shared endpoints

Multiple trigger files can share the same endpoint: one URL, many triggers, each with its own slug, request, filter, and transform. This is the pattern for a provider like Stripe that sends every event type to a single webhook URL.
// src/triggers/stripe-invoice-paid.ts  (endpoint: "stripe")
// src/triggers/stripe-subscription-deleted.ts  (endpoint: "stripe", different key/schema/filter)
Each incoming request is matched against every trigger on the endpoint; matching ones fire, and a payload that matches none returns { ok: true, skipped: true }. List the triggers on a shared endpoint:
keystroke triggers list --endpoint stripe
keystroke triggers url stripe          # one URL for the shared route
Use each trigger’s own slug for run history (keystroke triggers runs list stripe-invoice-paid --workflow <workflow-slug>).

Next steps

Advanced triggers

Transform payloads, attach agents, and interpolate prompts.

App events

Point a webhook at a connected third-party app.

Schedules

Run on a cron schedule instead of an inbound request.

Triggers overview

Sources, attach, and attachment ids.