> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.runtype.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.runtype.com/_mcp/server.

# Setting up a webhook Surface

Set up a webhook Surface that receives HTTP events from an external provider, verifies their signatures, and routes each event to a Flow or Agent Capability.

## Before you begin

Create a Product with at least one enabled Capability. Get access to the external provider's webhook settings so you can register the Webhook URL and copy its signing secret.

## Create a webhook Surface

To create a webhook Surface, follow these steps:

1. Open the Product that receives the events.
2. On the Product page, click **Surfaces**, then click **Add Surface**.
3. Select **Webhook**.
4. Enter a name for the Surface.
5. Connect each Capability that handles events to the Surface. In **Visual view**, drag a connection from the Capability node to the Surface node, then click **Save**. For more information, see [Connect a Capability to a Surface](/user-guide/products-surfaces/adding-capabilities-to-a-product#connect-a-capability-to-a-surface).
6. On the **Webhook** tab, copy the **Webhook URL**.
7. Register the URL in the external provider's webhook settings.

The Surface considers only enabled Capabilities that you connected to it. When no enabled Capability is connected, a delivery returns `404`, except while the Surface is learning a schema.

## Provide a payload shape

A webhook Surface uses a confirmed schema to describe the events it receives. On the **Webhook** tab, open **Setup** and choose one of these options:

* **Paste Schema**: paste an example JSON object payload, then click **Infer Schema**. The Surface moves to **Schema Proposed**. Runtype infers the schema from the pasted object as if it were an event, so paste a real event rather than a JSON Schema or AsyncAPI document.
* **Send Test**: click **Start Listening**, then send test events from the external provider. After three observed payloads, the Surface proposes a schema that marks each field as required or optional.

The **Status** badge on the **Webhook** tab shows one of these states:

* **Draft**: the Surface has no observations yet.
* **Listening**: the Surface collects test events to infer a schema.
* **Schema Proposed**: the Surface has a proposed schema for you to review.
* **Active**: you confirmed the schema, and the Surface runs a Capability for each event.
* **Drift Detected**: recent payloads differ significantly from the confirmed schema.

When the Surface reaches **Schema Proposed**, review the proposed schema and click **Confirm**. To start over, click **Clear** to remove the observations, then send another set of test events.

After you click **Start Listening** or **Infer Schema**, and until you confirm a schema, the Surface records each event as an observation. It doesn't run a Capability, so no execution appears in Logs. A new Surface on which you haven't chosen a setup option runs a Capability for each event that it receives.

If you send test events with a webhook API key, use a key that can reach all Capabilities. A key scoped to specific Capabilities receives `403` while the Surface is learning a schema.

## Verify webhook signatures

Configure signature verification after you confirm the schema. While the Surface is learning a schema, it rejects deliveries with `401` if a **Signing Secret** is saved and the request carries no webhook API key.

To verify signatures, follow these steps:

1. On the **Webhook** tab, open **Signature Verification**.
2. In **Provider**, select the provider that sends the events.
3. In **Signing Secret**, paste the secret that the provider uses to sign requests. For SendGrid, paste the verification key from SendGrid's signed event webhook settings.
4. Save your changes.

Runtype verifies signatures only when a **Signing Secret** is saved. Except while the Surface is learning a schema, every request then needs a valid signature, even a request that carries a webhook API key. When verification fails, Runtype returns `401` and doesn't run the Capability.

The supported providers send their signature in these headers:

* **Standard Webhooks**: `webhook-signature`.
* **Stripe**: `stripe-signature`.
* **GitHub**: `x-hub-signature-256`.
* **Shopify**: `x-shopify-hmac-sha256`.
* **Twilio**: `x-twilio-signature`.
* **SendGrid**: `x-twilio-email-event-webhook-signature`.
* **Slack**: `x-slack-signature`.
* **Custom HMAC-SHA256** and **Custom HMAC-SHA1**: `x-webhook-signature`.

For the custom providers, sign the raw request body with the secret. Send the signature hex-encoded for SHA-256 or base64-encoded for SHA-1, with an optional `sha256=` or `sha1=` prefix. The **Signature Header** field doesn't change the header that Runtype reads.

Standard Webhooks, Stripe, SendGrid, and Slack sign a timestamp. Runtype rejects those events when the timestamp differs from the current time by more than 5 minutes. The **Timestamp Tolerance (seconds)** field doesn't change this window.

Select **No verification** only for an internal source that doesn't sign requests. Selecting **No verification** doesn't remove a saved **Signing Secret**, and Runtype then verifies requests as **Custom HMAC-SHA256**. To turn off verification, clear **Signing Secret** and save before you select **No verification**.

> **Warning**
>
> With **No verification** and no webhook API key, anyone who has the Webhook URL can trigger the Capability.

To authenticate an internal source without signatures, open the **Keys** tab and click **Generate Key**. Send the key, which starts with `wh_`, as `Authorization: Bearer wh_YOUR_KEY` or in the `X-API-Key` header. Replace `wh_YOUR_KEY` with the full key. After the Surface has an active key, it rejects requests that carry neither a valid key nor a valid signature.

## Route events to Capabilities

When one webhook Surface receives several event types, route each type to a Capability. To configure event routing, follow these steps:

1. Open the **Routing** tab.
2. Set **Event Type Path** to the dot path of the field that names the event, such as `type` for Stripe.
3. Add a route for each **Event Pattern** and select its **Capability**. A pattern such as `payment_intent.*` matches a family of event types.
4. Optional: select a **Fallback Capability** for events that match no route.
5. Click **Save Routing**.

If the **Event Type Path** field is missing from a payload, Runtype reads the event name from the `type`, `event`, `event_type`, or `action` field, or from the `x-github-event` header.

An event that matches no route and has no fallback goes to a Capability whose name matches the event name. If no name matches, the event goes to the first connected Capability. With a webhook API key scoped to specific Capabilities, it goes to the first connected Capability that the key can reach.

## Read the event in a Flow

Runtype sends each event to the Capability as the user message, available as `{{userMessage}}`. The message is a JSON object with these fields:

* `event`: the detected event name, or `webhook` when Runtype can't detect one.
* `payload`: the complete parsed request body.
* `metadata`: the event ID in `webhook_event_id`, the time Runtype received the event in `webhook_timestamp`, and whether the signature was verified in `signature_verified`.

To read nested fields from the message, parse it in a step, such as a transform step.

By default, Runtype also passes each top-level payload field as a Flow input with the same name. For example, `{{customerId}}` resolves when the payload has a `customerId` field. Runtype omits top-level fields that start with `_` because that prefix is reserved.

To pass nested or renamed fields as inputs, set `inputMapping` in the Surface's `behavior` when you define the Product in code or update the Surface through the API. Each key is a Flow input name, and each value is a dot path into the payload. This example maps two nested Stripe fields:

**`Surface behavior with an input mapping`**

```json title="Surface behavior with an input mapping"
{
  "type": "webhook",
  "inputMapping": {
    "amount": "data.object.amount",
    "customerId": "data.object.customer"
  }
}
```

When you set an input mapping, Runtype passes only the mapped fields as Flow inputs. Map every field that the Flow reads as an input. The **Mappings** tab in the dashboard doesn't change the inputs that a delivery passes to the Flow.

### Supported request bodies

Runtype parses the request body based on its `Content-Type` header:

* **JSON** (`application/json`, any `+json` type, or no content type): Runtype parses the body. Invalid JSON returns `400`.
* **Form-encoded** (`application/x-www-form-urlencoded`): each form field becomes a string field, such as `Body` and `From` for Twilio.
* **Multipart** (`multipart/form-data`): not supported. Runtype returns `400`.
* **Any other type**: the payload is `{ "raw": "BODY" }`, so the only input is `raw`.

## Choose a response mode

On the **Webhook** tab, **Response Mode** defaults to **Async**. Choose the mode that matches what the provider expects in the response:

* **Async**: Runtype accepts the event, responds right away with `202`, and runs the Capability afterward. Use this mode when the provider doesn't need the execution result.
* **Synchronous**: the provider waits for the Flow to finish and receives its result. Synchronous mode supports Flow Capabilities only. For an Agent Capability, use **Async** or **Custom response**.
* **Custom response**: Runtype returns a fixed response that you define and runs the Capability afterward.

An Async response looks like this example:

**`Async response (202)`**

```json title="Async response (202)"
{
  "success": true,
  "mode": "async",
  "message": "Webhook received and queued for processing",
  "eventId": "evt_123",
  "capability": "handle-payment",
  "executionId": "exec_EXECUTION_ID"
}
```

A Synchronous response returns the Flow result as a string in the `response` field. A provider that parses the reply as a bare body can't use this wrapper, so use **Custom response** instead:

**`Synchronous response (200)`**

```json title="Synchronous response (200)"
{
  "success": true,
  "mode": "sync",
  "eventId": "evt_123",
  "capability": "handle-payment",
  "response": "FLOW_RESULT",
  "executionId": "exec_EXECUTION_ID"
}
```

If the Flow fails, a Synchronous request returns `500`.

To return a custom response, follow these steps:

1. In **Response Mode**, select **Custom response**.
2. Click a preset, or fill in the fields yourself. **Twilio (empty TwiML)** returns an empty TwiML document as `application/xml`. **Empty (204 No Content)** returns status `204` with no body, which suits Shopify.
3. Optional: edit **Content-Type**, **Status**, and **Body**. **Status** must be from 200 to 599 and defaults to 200.
4. Save your changes.

### Retries and duplicate events

An Async or custom acknowledgment confirms that Runtype accepted the event, not that the execution succeeded. Runtype returns `200` for a duplicate delivery of an accepted event. With a custom response, a duplicate receives the same custom response.

Runtype identifies an Async duplicate by the first ID that it finds, in this order:

1. The `x-github-delivery`, `x-shopify-webhook-id`, or `webhook-id` header.
2. The `id` or `event_id` field in the body.
3. The raw request body, when neither an ID header nor an ID field exists.

Duplicate suppression lasts 24 hours from acceptance. Keep the event ID unchanged when you retry a delivery. For repeated events with identical bodies, such as a scheduled integration without an event ID, send a different `webhook-id` header for each event.

If Runtype can't accept an event, it returns `500` or `503`, including in **Custom response** mode. Configure the provider to retry these errors. In **Async** and **Custom response** modes, the event and its Flow inputs together must be 8 MiB or smaller. Runtype stores the payload both in the message and as inputs, so a request smaller than 8 MiB can exceed the limit. Runtype returns `413` for a larger event.

## Test the connection

To verify the connection, follow these steps:

1. Send a test event from the external provider.
2. If the Surface is **Listening**, send two more test events so that it can propose a schema.
3. When the Surface reaches **Schema Proposed**, review the schema and click **Confirm**.
4. Send another test event.
5. In the provider's delivery history, confirm that the response names the expected `capability`. A **Custom response** doesn't name the Capability.
6. In [Logs](/user-guide/logs/what-are-logs), open the execution and inspect the input that the Flow received.

When the Surface shows **Drift Detected**, it keeps running a Capability for each event. Check that the Flow still handles the changed payload fields.

## Troubleshoot webhook setup

Runtype rejects some deliveries before it runs a Capability, so they don't appear in Logs. Check the provider's delivery history for the status code and the response body.

The following table lists the rejections that you might see:

| Status                    | Cause                                                                                                                                                 | Fix                                                                                                 |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `400` `PARSE_ERROR`       | The body is invalid JSON or multipart form data.                                                                                                      | Send JSON, form-encoded, or plain text.                                                             |
| `400` `SURFACE_PAUSED`    | The Surface is paused.                                                                                                                                | Resume the Surface.                                                                                 |
| `400` `Event not allowed` | The event matches no route, and the Surface's `events` list doesn't include it.                                                                       | Add the event to the `events` list in the Surface's `behavior`, or route the event to a Capability. |
| `400` `EXPIRED_KEY`       | The webhook API key has expired.                                                                                                                      | Generate a key on the **Keys** tab.                                                                 |
| `401` `INVALID_SIGNATURE` | The signature doesn't match.                                                                                                                          | Check the **Provider**, the **Signing Secret**, and the header that the provider sends.             |
| `401` `UNAUTHORIZED`      | The Surface has a signing secret or an active webhook API key, and the request carries neither a valid signature nor a valid key.                     | Send a signed request or a valid `wh_` key.                                                         |
| `403`                     | The webhook API key can't reach the routed Capability, or a key scoped to specific Capabilities sent an event while the Surface is learning a schema. | Use a key that can reach all Capabilities.                                                          |
| `404`                     | The Webhook URL is wrong, or no enabled Capability is connected to the Surface.                                                                       | Copy the URL again from the **Webhook** tab, and connect an enabled Capability.                     |
| `429` `RATE_LIMITED`      | The request exceeded a rate limit.                                                                                                                    | Retry later. If the response has a `Retry-After` header, wait that many seconds.                    |
| `501`                     | A Synchronous Surface routed the event to an Agent Capability.                                                                                        | Select **Async** or **Custom response**.                                                            |

Use these checks for other problems:

* **The Surface stays in Listening**: send three test events with JSON object payloads, and confirm that they reach the Webhook URL.
* **Unexpected routing**: check **Event Type Path**, the route patterns, and the **Fallback Capability**. Without a matching route or fallback, an event goes to the Capability named after the event, or else to the first connected Capability.
* **A Flow input is empty**: if you set an input mapping, map every input that the Flow reads.

For provider-specific checks, see [Inbound webhook rejected](/user-guide/troubleshooting-faq/common-errors-and-solutions#inbound-webhook-rejected).

## Next steps

Continue with these guides:

* [What are Surfaces?](/user-guide/products-surfaces/what-are-surfaces): understand how a webhook Surface fits into a Product.
* [Adding capabilities to a Product](/user-guide/products-surfaces/adding-capabilities-to-a-product): attach Flows or Agents to the Product and connect them to Surfaces.
* [Scoping API keys to Capabilities](/user-guide/products-surfaces/scoping-api-keys-to-capabilities): limit which Capabilities a key can reach.
* [Common errors and solutions](/user-guide/troubleshooting-faq/common-errors-and-solutions): fix rejected webhook deliveries.
* [Working with logs](/user-guide/logs/working-with-logs): inspect Flow executions and their inputs.