---
title: "Webhook Workflow"
description: "Build a webhook-triggered workflow that accepts HTTP POST requests and processes them durably. Your first step toward handling external events."
canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/webhook-workflow"
md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/webhook-workflow.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-11T10:12:59.597Z"
content_type: "lesson"
course: "visual-workflow-builder-on-vercel"
course_title: "Build Visual Workflow Plugins on Vercel"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Webhook Workflow

# Build a Webhook-Triggered Workflow

In Lesson 1, you clicked "Run" to start a workflow. That's fine for testing, but real workflows start from external events — Stripe fires a payment webhook, GitHub pushes a commit event, a form submits data. The workflow needs to wake up when those events arrive.

Webhook triggers solve this. The workflow gets a URL. POST to that URL, and the workflow runs with whatever data you sent. The API returns instantly (same pattern as [Hello Workflow](/visual-workflow-builder-on-vercel/hello-workflow)), and the workflow processes the event durably in the background.

\*\*Note: Mental Model: Pause and Continue\*\*

The workflow has a URL. It waits for a POST. When the POST arrives, execution continues exactly where it left off. No polling, no state management — the workflow literally suspends and resumes. This is the same pattern as [`createWebhook()`](https://useworkflow.dev/docs/api-reference/workflow/create-webhook) in the SDK.

## Outcome

You'll build a webhook-triggered workflow, POST JSON to it with curl, and watch the workflow process that data through multiple steps.

## Fast Track

1. Create a new workflow with Webhook trigger
2. Add an HTTP Request action that echoes the data
3. Copy the webhook URL and POST to it with curl

## Hands-on Exercise

### 1. Create a Webhook-Triggered Workflow

1. Click **New Workflow** (or modify your existing one)
2. Click the **Trigger** node to open the properties panel
3. Change **Trigger Type** from "Manual" to **Webhook**
4. Change the **Label** from "Manual Trigger" to **Webhook Trigger**
5. You'll see a **Webhook URL** appear — copy it (or wait until after you save)

The URL looks like:

```
http://localhost:3000/api/workflows/abc123/webhook
```

### 2. Add an HTTP Request Action

We'll use [JSONPlaceholder](https://jsonplaceholder.typicode.com/) to echo back our data. This proves the webhook payload flows through the workflow.

1. Click the **+** button after the trigger
2. Select **HTTP Request** from the action grid
3. Configure it:
   - **URL:** `https://jsonplaceholder.typicode.com/posts`
   - **HTTP Method:** `POST`
   - **Body:**
     ```json
     {
       "title": "{{@trigger-1:Webhook Trigger.title}}",
       "body": "{{@trigger-1:Webhook Trigger.body}}",
       "userId": 1
     }
     ```

\*\*Warning: Template Syntax Must Match\*\*

The syntax `{{@trigger-1:Webhook Trigger.title}}` has three parts:

- **`trigger-1`** — the node ID (visible in the properties panel)
- **`Webhook Trigger`** — the node label (must match exactly)
- **`.title`** — the property from the trigger payload

**Both the node ID and label must match.** If you skipped step 4 (renaming the label), your template won't resolve. Go back and change the label to "Webhook Trigger" to match the template.

### 3. Save the Workflow

Click **Save** in the toolbar. The webhook URL is now active.

\*\*Note: Webhook URL Only Works After Save\*\*

The URL includes the workflow ID, which is assigned when you save. No save = no URL.

### 4. Set Up Mock Data for Testing

Before we use curl, let's set up mock data so you can also test from the UI:

1. Click the **Trigger** node
2. Find **Mock Request (Optional)**
3. Enter:
   ```json
   {
     "title": "Test from UI",
     "body": "This is mock data for testing"
   }
   ```

Now you can click **Run** in the toolbar to test with mock data, or POST real data via curl.

## Try It

Open a terminal and POST to your webhook URL:

```bash
curl -X POST http://localhost:3000/api/workflows/YOUR_WORKFLOW_ID/webhook \
  -H "Content-Type: application/json" \
  -d '{"title": "Hello from curl", "body": "Webhook triggered!"}'
```

You should see:

```json
{"executionId":"exec_xyz","status":"running"}
```

The response came back instantly — the workflow is running in the background.

Check your terminal (where `pnpm dev` is running):

```
[Webhook] Starting execution: exec_xyz
[Webhook] Calling executeWorkflow with: { nodeCount: 2, edgeCount: 1, ... }
[Workflow Executor] Starting workflow execution
[Workflow Executor] Executing trigger node
[Workflow Executor] Executing action node: HTTP Request
[Workflow Executor] Workflow execution completed: { success: true, ... }
```

Check the **Runs** panel in the UI — you'll see the execution with:

- Trigger node showing the data you POSTed
- HTTP Request node showing the response from JSONPlaceholder

\*\*Reflection:\*\* If you POST { 'title': 'My Title' } (without a 'body' field), what will the HTTP Request send to JSONPlaceholder? What will the template {{@trigger-1:Webhook Trigger.body}} resolve to?

## Solution

The webhook flow works like this:

1. **External system POSTs** to your workflow's webhook URL with JSON data
2. **API records the execution** and returns immediately with `{ executionId, status: "running" }`
3. **Workflow runs in background** with the POST body available as trigger output
4. **Template variables** like `{{@trigger-1:Webhook Trigger.fieldName}}` pull data from the payload

**When to use webhooks vs manual triggers:**

| Use Case                                   | Trigger Type                              |
| ------------------------------------------ | ----------------------------------------- |
| Third-party events (Stripe, GitHub, Slack) | Webhook                                   |
| Scheduled jobs (via external cron)         | Webhook                                   |
| User-initiated actions in your app         | Webhook (POST from your frontend/backend) |
| Testing and development                    | Manual (with mock data)                   |
| One-off administrative tasks               | Manual                                    |

Webhooks are the production pattern. Manual triggers exist for testing workflows before wiring them to real event sources.

## What's Happening

The webhook endpoint does three things:

1. **Validates** — checks the workflow exists and is configured for webhooks
2. **Records** — creates an execution record with the incoming payload
3. **Starts** — calls `start(executeWorkflow, [...])` and returns immediately

```mermaid height=1200
flowchart TD
    A[curl POST] --> B["/api/workflows/[id]/webhook"]
    B --> C{Validate}
    C -->|workflow exists?<br/>trigger = webhook?| D[Record execution]
    D -->|insert row<br/>status: running| E{Fork}
    E --> F[Return 200 immediately]
    E --> G["start() background execution"]
    F --> H["{ executionId }"]
```

```typescript title="app/api/workflows/[workflowId]/webhook/route.ts"
// Parse the incoming POST body
const body = await request.json().catch(() => ({}));

// Create execution record with the webhook payload
const [execution] = await db
  .insert(workflowExecutions)
  .values({
    workflowId,
    userId: workflow.userId,
    status: "running",
    input: body,  // ← Your curl data lands here
  })
  .returning();

// Start workflow in background (don't await)
executeWorkflowBackground(
  execution.id,
  workflowId,
  workflow.nodes,
  workflow.edges,
  body  // ← And gets passed to the workflow
);

// Return immediately
return NextResponse.json({
  executionId: execution.id,
  status: "running",
});
```

The workflow receives the POST body as `triggerInput`. Template variables like `{{@trigger-1:Webhook Trigger.title}}` pull from this data.

```yaml
quiz:
  question: "You POST { 'user': 'alice', 'action': 'signup' } to the webhook. In your HTTP Request body, how do you access the 'action' field?"
  choices:
    - id: "a"
      text: "{{action}}"
    - id: "b"
      text: "{{@trigger-1:Webhook Trigger.action}}"
    - id: "c"
      text: "{{webhook.action}}"
    - id: "d"
      text: "{{input.action}}"
  correctAnswerId: "b"
  feedback: "{\n    correct: \"Right. The trigger node's output is accessed via {{@trigger-1:Webhook Trigger.fieldName}}. The @trigger is the node ID, Trigger is the label, and .action accesses the field.\",\n    incorrect: \"Template variables follow the pattern {{@nodeId:Label.field}}. For the trigger node, that's {{@trigger-1:Webhook Trigger.action}}.\"\n  }"
```

\*\*Warning: Production: Verify Webhook Signatures\*\*

This starter skips signature verification for simplicity. In production, always verify webhook signatures to prevent replay attacks and spoofed requests. Stripe, GitHub, and most providers include a signature header — see [Stripe's webhook signature docs](https://docs.stripe.com/webhooks#verify-official-libraries) for a reference implementation.

```typescript title="Example: Stripe webhook signature verification"
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  // Read raw body for signature verification
  const body = await request.text();
  const sig = request.headers.get('stripe-signature')!;
  
  try {
    // Verify the webhook came from Stripe
    const event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
    
    // Safe to process - signature verified
    // Pass event.data.object to your workflow...
    
  } catch (err) {
    // Invalid signature - reject the request
    return new Response('Invalid signature', { status: 400 });
  }
}
```

## Debugging: "This workflow is not configured for webhook triggers"

If you see this error when POSTing:

```json
{"error":"This workflow is not configured for webhook triggers"}
```

The trigger type is still set to "Manual". Open the workflow, click the Trigger node, and change the Trigger Type dropdown to "Webhook". Save again.

## Debugging: Empty Trigger Data

If your HTTP Request step shows empty strings where you expected data:

1. **Check your curl command** — is the JSON valid? Missing quotes?
2. **Check the template syntax** — it's `{{@nodeId:Label.fieldName}}`, not `{{trigger.fieldName}}`
3. **Check the node ID** — click your trigger node and verify the ID matches (e.g., `trigger-1`)
4. **Check field names** — `.title` won't find `Title` (case matters)

## Commit

```bash
git add -A
git commit -m "feat: add webhook-triggered workflow with HTTP request"
```

## Done

- [ ] Changed trigger type to Webhook
- [ ] Copied the webhook URL
- [ ] Added HTTP Request action with template variables
- [ ] POSTed JSON with curl
- [ ] Saw instant response, workflow completed in background
- [ ] Verified trigger data flowed to the HTTP Request step

\*\*Note: Learn More\*\*

The Workflow SDK provides powerful primitives for handling external events. See [Hooks & Webhooks](https://useworkflow.dev/docs/foundations/hooks) for patterns like waiting for multiple events, custom tokens, and manual response handling.

## What's Next

Webhook triggers let external systems start your workflows. But the built-in actions (Log, HTTP Request, Condition) are limited. What if you want to send emails, post to Slack, create Linear tickets, or call your internal APIs?

That's where plugins come in. Lesson 3 teaches you the plugin folder pattern — how to extend the workflow builder with your own actions.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
