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.

workflows/slack-events.ts
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 token lets 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.

workflows/github-webhook.ts
import { createWebhook, type RequestWithResponse } from "workflow";

interface GitHubEvent {
  action?: string;
  repository: { full_name: string };
  sender: { login: string };
  [key: string]: unknown;
}

async function verifyAndParse(
  request: RequestWithResponse,
  secret: string
): Promise<{ eventType: string; payload: GitHubEvent }> {
  "use step"; 

  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) {
    throw new Error("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"; 

  const webhook = createWebhook({
    token: `github:${repoName}`,
    respondWith: "manual",
  });

  for await (const request of webhook) {
    const { eventType, payload } = await verifyAndParse(
      request,
      process.env.GITHUB_WEBHOOK_SECRET!
    );

    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.

workflows/webhook-forwarder.ts
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

For recurring tasks, combine sleep() with step execution to build a durable cron-like processor. Unlike traditional cron jobs, the workflow maintains state between iterations and survives restarts.

workflows/scheduled-sync.ts
import { sleep } from "workflow";

async function fetchLatestData(): Promise<{ count: number; updatedAt: string }> {
  "use step";

  const response = await fetch("https://api.example.com/data");
  const data = await response.json();
  return data as { count: number; updatedAt: string };
}

async function syncToDatabase(data: { count: number; updatedAt: string }) {
  "use step";
  console.log("Syncing", data.count, "records from", data.updatedAt);
  // Write to your database
}

export async function scheduledSync(intervalMinutes: number, maxRuns: number) {
  "use workflow";

  for (let i = 0; i < maxRuns; i++) { 
    const data = await fetchLatestData();
    await syncToDatabase(data);

    console.log(`Run ${i + 1}/${maxRuns} complete`);

    if (i < maxRuns - 1) {
      await sleep(`${intervalMinutes} minutes`); 
    }
  }

  console.log("Scheduled sync complete");
}

sleep() is durable - if the workflow restarts during a sleep, it resumes when the original sleep duration expires rather than starting over. See Common Patterns for more on combining sleep() with Promise.race for timeouts.

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.

workflows/simple-receiver.ts
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>; // @setup

Static Response

Provide a fixed Response object that is returned for every request. Use this when the caller expects a specific response format.

workflows/static-response.ts
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>; // @setup

Dynamic Response (Manual Mode)

Set respondWith: "manual" to control the response from a step function. This is required when the response depends on request content.

workflows/dynamic-response.ts
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>; // @setup

respondWith() must be called from a step function. See the Hooks & Webhooks guide for details on this requirement.