Webhook Integrations
Receive and process webhooks from external services like Slack, GitHub, and third-party APIs using durable workflows.
Webhooks are how external services communicate events to your application. Workflow DevKit turns webhook handlers into durable processors that survive failures, replay deterministically, and scale without additional infrastructure.
This guide covers common webhook integration patterns using createWebhook() and createHook(). If you are new to these primitives, start with the Hooks & Webhooks foundation guide first.
Slack Event Processing
A common pattern is building a workflow that receives Slack events via webhook and processes each message as a durable step. Use a custom token based on the channel ID so your Slack webhook handler can route events to the correct workflow instance.
import { createWebhook, type RequestWithResponse } from "workflow";
interface SlackEvent {
type: string;
user: string;
text: string;
channel: string;
ts: string;
}
async function acknowledgeSlack(request: RequestWithResponse) {
"use step";
await request.respondWith(
new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
})
);
}
async function processSlackEvent(event: SlackEvent) {
"use step";
if (event.type === "message") {
// Call your internal APIs, update a database, trigger notifications
console.log(`[${event.channel}] ${event.user}: ${event.text}`);
}
}
export async function slackEventProcessor(channelId: string) {
"use workflow";
const webhook = createWebhook({
token: `slack_events:${channelId}`,
respondWith: "manual",
});
for await (const request of webhook) {
const body = await request.json();
// Acknowledge immediately so Slack does not retry
await acknowledgeSlack(request);
// Process each event as a durable step
await processSlackEvent(body.event);
if (body.event?.text === "stop") {
break;
}
}
}Key points:
- The custom
tokenlets your Slack API route reconstruct the webhook URL for any channel respondWith: "manual"allows you to acknowledge the request before processing- Each event is processed in a step, so failures retry without losing the event
GitHub Webhook Handler
GitHub sends webhooks for pushes, pull requests, issues, and other repository events. Build a workflow that receives these events, verifies the signature, and routes to the appropriate handler.
import { createWebhook, FatalError, type RequestWithResponse } from "workflow";
interface GitHubEvent {
action?: string;
repository: { full_name: string };
sender: { login: string };
[key: string]: unknown;
}
async function verifyAndParse(
request: RequestWithResponse
): Promise<{ eventType: string; payload: GitHubEvent }> {
"use step";
const secret = process.env.GITHUB_WEBHOOK_SECRET!;
const signature = request.headers.get("x-hub-signature-256") ?? "";
const body = await request.text();
// Verify HMAC signature
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
const expected = "sha256=" + Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (signature !== expected) {
await request.respondWith(new Response("Unauthorized", { status: 401 }));
throw new FatalError("Invalid GitHub webhook signature");
}
await request.respondWith(new Response("OK", { status: 200 }));
const eventType = request.headers.get("x-github-event") ?? "unknown";
return { eventType, payload: JSON.parse(body) };
}
async function handlePush(payload: GitHubEvent) {
"use step";
console.log(`Push to ${payload.repository.full_name} by ${payload.sender.login}`);
// Trigger builds, run tests, update dashboards
}
async function handlePullRequest(payload: GitHubEvent) {
"use step";
console.log(`PR ${payload.action} on ${payload.repository.full_name}`);
// Run checks, post comments, update project boards
}
async function handleIssue(payload: GitHubEvent) {
"use step";
console.log(`Issue ${payload.action} on ${payload.repository.full_name}`);
// Triage, assign, notify team
}
export async function githubWebhookHandler(repoName: string) {
"use workflow";
// Replace slashes to keep tokens URL-safe
const safeRepoName = repoName.replace("/", ":");
const webhook = createWebhook({
token: `github:${safeRepoName}`,
respondWith: "manual",
});
for await (const request of webhook) {
const { eventType, payload } = await verifyAndParse(request);
switch (eventType) {
case "push":
await handlePush(payload);
break;
case "pull_request":
await handlePullRequest(payload);
break;
case "issues":
await handleIssue(payload);
break;
}
}
} Signature verification runs as a step so it has full access to Node.js crypto APIs. Workflow functions run in a sandboxed VM without direct access to these modules.
Webhook Forwarding
Some integrations require forwarding a single webhook to multiple downstream systems. Use Promise.all to fan out in parallel, with each forwarding call as an independent retryable step.
import { createWebhook, type RequestWithResponse } from "workflow";
async function acknowledgeRequest(request: RequestWithResponse) {
"use step";
await request.respondWith(
Response.json({ status: "forwarding" }, { status: 202 })
);
}
async function forwardToEndpoint(
url: string,
payload: string,
headers: Record<string, string>
): Promise<{ url: string; status: number }> {
"use step";
const response = await fetch(url, {
method: "POST",
body: payload,
headers: { "Content-Type": "application/json", ...headers },
});
if (!response.ok) {
// Throwing here triggers automatic retry
throw new Error(`Forward to ${url} failed: ${response.status}`);
}
return { url, status: response.status };
}
export async function webhookForwarder(endpoints: string[]) {
"use workflow";
const webhook = createWebhook({ respondWith: "manual" });
for await (const request of webhook) {
const payload = await request.text();
await acknowledgeRequest(request);
// Fan out to all endpoints in parallel
const results = await Promise.all(
endpoints.map((url) => forwardToEndpoint(url, payload, {}))
);
console.log("Forwarded to", results.length, "endpoints");
}
}Because each forwardToEndpoint call is a separate step, a failure to one endpoint retries independently without affecting the others. The workflow is durable across all of them.
Scheduled Task Processing
Webhooks are often paired with scheduled polling. The core pattern combines sleep() with step execution in a loop:
import { sleep } from "workflow";
declare function fetchAndSync(): Promise<void>; // @setup
export async function scheduledSync() {
"use workflow";
while (true) {
await fetchAndSync();
await sleep("30 minutes");
}
}sleep() is durable — if the workflow restarts during a sleep, it resumes when the original duration expires. For comprehensive scheduling patterns including cron-like dispatching, health checks, and graceful shutdown, see Scheduling & Cron.
Webhook Response Patterns
Workflow DevKit supports three response modes for webhooks. Choose the one that fits your integration requirements.
Default (202 Accepted)
When no respondWith option is set, the webhook automatically returns 202 Accepted to the caller. This is the simplest option for services that only need delivery confirmation.
import { createWebhook } from "workflow";
export async function simpleReceiver() {
"use workflow";
// Caller receives 202 Accepted automatically
const webhook = createWebhook();
const request = await webhook;
const data = await request.json();
await processEvent(data);
}
declare function processEvent(data: unknown): Promise<void>; // @setupStatic Response
Provide a fixed Response object that is returned for every request. Use this when the caller expects a specific response format.
import { createWebhook } from "workflow";
export async function staticResponseWebhook() {
"use workflow";
const webhook = createWebhook({
respondWith: Response.json({ received: true, status: "processing" }),
});
const request = await webhook;
const data = await request.json();
await processEvent(data);
}
declare function processEvent(data: unknown): Promise<void>; // @setupDynamic Response (Manual Mode)
Set respondWith: "manual" to control the response from a step function. This is required when the response depends on request content.
import { createWebhook, type RequestWithResponse } from "workflow";
async function respond(request: RequestWithResponse, body: Record<string, unknown>, status: number) {
"use step";
await request.respondWith(
Response.json(body, { status })
);
}
export async function dynamicResponseWebhook() {
"use workflow";
const webhook = createWebhook({ respondWith: "manual" });
const request = await webhook;
const data = await request.json();
if (!data.type) {
await respond(request, { error: "Missing type field" }, 400);
return;
}
await respond(request, { accepted: true, type: data.type }, 200);
await processEvent(data);
}
declare function processEvent(data: unknown): Promise<void>; // @setuprespondWith() must be called from a step function. See the Hooks & Webhooks guide for details on this requirement.
Related Documentation
- Hooks & Webhooks - Core hook and webhook primitives
- Common Patterns - Sequential, parallel, and timeout patterns
createWebhook()API Reference - Full webhook APIcreateHook()API Reference - Low-level hook API- Errors and Retries - Retry semantics for step functions