---
title: Webhooks & External Callbacks
description: Receive HTTP callbacks from external services, process them durably, and respond inline.
type: guide
summary: Create webhook endpoints that your workflow can await, process incoming requests in steps, and respond to the caller, all within durable workflow context.
---

<CopyPrompt
  text="Implement durable webhook handling for this workflow. Use `createWebhook()` from `workflow` inside the &quot;use workflow&quot; function when the workflow needs a generated callback URL. Use `createWebhook({ respondWith: &quot;manual&quot; })` when the workflow must validate the request before sending an HTTP response. Await the webhook request, pass the `RequestWithResponse` into a &quot;use step&quot; function for validation and side effects, and call `request.respondWith(Response.json(...))` on every code path. Race the webhook against `sleep()` for deadlines and throw `FatalError` for permanent timeout/failure paths. For fixed public callbacks or large payloads, use `defineHook()` plus `resumeHook()` from `workflow/api` and pass only a token or blob reference into the hook payload. Verify success response, invalid response, timeout, duplicate callback, and large-payload behavior."
/>

Use webhooks when external services push events to your application via HTTP callbacks. The workflow creates a webhook URL, suspends with zero compute cost, and resumes when a request arrives.

## When to use this

- Accepting callbacks from payment processors (Stripe, PayPal)
- Waiting for third-party verification or processing results
- Any integration where an external system calls you back asynchronously

## Pattern: processing webhook events

Create a webhook with manual response control, then iterate over incoming requests:

```typescript
import { createWebhook, type RequestWithResponse } from "workflow";

declare function processEvent(request: RequestWithResponse): Promise<{ type: string }>; // @setup

export async function paymentWebhook(orderId: string) {
  "use workflow";

  const webhook = createWebhook({ respondWith: "manual" }); // [!code highlight]
  // webhook.url is the URL to give to the external service

  const ledger: { type: string }[] = [];

  for await (const request of webhook) { // [!code highlight]
    const entry = await processEvent(request);
    ledger.push(entry);

    // Break when we've received a terminal event
    if (entry.type === "payment.succeeded" || entry.type === "refund.created") {
      break;
    }
  }

  return { orderId, webhookUrl: webhook.url, ledger, status: "settled" };
}
```

### Step function for processing

Each webhook request is processed in its own step, giving you full Node.js access for validation, database writes, and responding to the caller. In production, verify the provider's signature here before trusting the body; the example omits it for brevity.

```typescript
import { type RequestWithResponse } from "workflow";

async function processEvent(
  request: RequestWithResponse
): Promise<{ type: string }> {
  "use step";

  const body = await request.json().catch(() => ({}));
  const type = body?.type ?? "unknown";

  // Validate, process, and respond inline
  if (type === "payment.succeeded") {
    // Record the payment in your database
    await request.respondWith(Response.json({ ack: true, action: "captured" })); // [!code highlight]
  } else if (type === "payment.failed") {
    await request.respondWith(Response.json({ ack: true, action: "flagged" }));
  } else {
    await request.respondWith(Response.json({ ack: true, action: "ignored" }));
  }

  return { type };
}
```

## Pattern: async request-reply with timeout

Submit a request to an external service, pass it your webhook URL, then race the callback against a deadline:

```typescript
import { createWebhook, sleep, FatalError, type RequestWithResponse } from "workflow";

export async function asyncVerification(documentId: string) {
  "use workflow";

  const webhook = createWebhook({ respondWith: "manual" });

  // Submit to vendor, passing our webhook URL for the callback
  await submitToVendor(documentId, webhook.url);

  // Race: wait for callback OR timeout after 30 seconds
  const result = await Promise.race([ // [!code highlight]
    (async () => {
      for await (const request of webhook) {
        const body = await processCallback(request);
        return body;
      }
      throw new FatalError("Webhook closed without callback");
    })(),
    sleep("30s").then(() => ({ status: "timed_out" as const })), // [!code highlight]
  ]);

  return { documentId, ...result };
}

async function submitToVendor(documentId: string, callbackUrl: string): Promise<void> {
  "use step";
  await fetch("https://vendor.example.com/verify", {
    method: "POST",
    body: JSON.stringify({ documentId, callbackUrl }),
  });
}

async function processCallback(
  request: RequestWithResponse
): Promise<{ status: string; details: string }> {
  "use step";
  const body = await request.json();
  await request.respondWith(Response.json({ ack: true }));
  return {
    status: body.approved ? "verified" : "rejected",
    details: body.details ?? body.reason ?? "",
  };
}
```

## Pattern: large payload by reference

When payloads are too large to serialize into the event log, pass a lightweight reference (a "claim check") instead. Use a hook to signal when the data is ready:

```typescript
import { defineHook } from "workflow";

export const blobReady = defineHook<{ blobToken: string }>(); // [!code highlight]

export async function importLargeFile(importId: string) {
  "use workflow";

  // Suspend until the external system signals the blob is uploaded
  const { blobToken } = await blobReady.create({ token: `upload:${importId}` }); // [!code highlight]

  // Process by reference. The full payload never enters the event log.
  await processBlob(blobToken);

  return { importId, blobToken, status: "indexed" };
}

async function processBlob(blobToken: string): Promise<void> {
  "use step";
  // Fetch the blob using the token, process it
  const res = await fetch(`https://storage.example.com/blobs/${blobToken}`);
  const data = await res.arrayBuffer();
  // Index, transform, or store the data
}
```

Resume from an API route when the upload completes:

```typescript
import { resumeHook } from "workflow/api";

// POST /api/upload-complete
export async function POST(request: Request) {
  const { importId, blobToken } = await request.json();
  await resumeHook(`upload:${importId}`, { blobToken }); // [!code highlight]
  return Response.json({ ok: true });
}
```

## Tips

- **`respondWith: "manual"`** gives you control over the HTTP response from inside a step. Use this when you need to validate the request before responding.
- **`for await` on a webhook** lets you process multiple events from the same URL. Use `break` to stop listening after a terminal event.
- **Webhooks auto-generate URLs** at `/.well-known/workflow/v1/webhook/:token`. Pass this URL to external services.
- **The URL is the only authorization.** Anyone with the webhook URL can send it a request, so verify the provider's signature in the processing step before acting on the payload. See [Hook and webhook security](/docs/foundations/hooks#security).
- **Race webhooks against `sleep()`** for deadlines. If the callback doesn't arrive in time, the workflow can take a fallback action.
- **For large payloads**, use a hook and reference token instead of passing the data through the workflow. The event log serializes all step inputs and outputs, so large payloads hurt performance.

## Key APIs

- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions with full Node.js access
- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) -- creates an HTTP endpoint the workflow can await
- [`defineHook()`](/docs/api-reference/workflow/define-hook) -- creates a typed hook for signal-based patterns
- [`sleep()`](/docs/api-reference/workflow/sleep) -- durable timer for deadlines
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- prevents retry on permanent failures
