Webhooks & External Callbacks
Receive HTTP callbacks from external services, process them durably, and respond inline.
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:
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" });
// webhook.url is the URL to give to the external service
const ledger: { type: string }[] = [];
for await (const request of webhook) {
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:
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" }));
} 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:
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([
(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 })),
]);
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:
import { defineHook } from "workflow";
export const blobReady = defineHook<{ blobToken: string }>();
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}` });
// 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:
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 });
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 awaiton a webhook lets you process multiple events from the same URL. Usebreakto stop listening after a terminal event.- Webhooks auto-generate URLs at
/.well-known/workflow/v1/webhook/:token. Pass this URL to external services. - 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 + reference token instead of passing the data through the workflow. The event log serializes all step inputs/outputs, so large payloads hurt performance.
Key APIs
"use workflow"-- marks the orchestrator function"use step"-- marks functions with full Node.js accesscreateWebhook()-- creates an HTTP endpoint the workflow can awaitdefineHook()-- creates a typed hook for signal-based patternssleep()-- durable timer for deadlinesFatalError-- prevents retry on permanent failures