> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbitforms.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Inbound Workflow Webhook

> Send form-shaped JSON from external systems to trigger a specific OrbitForms workflow via a signed webhook URL.

## Overview

An **inbound workflow webhook** lets external systems (your backend, Zapier, Make, custom scripts) POST data into OrbitForms and run a **specific workflow** — without using the public form embed.

<Info>
  Inbound webhooks are **always tied to a Form trigger**. You must select a form on the workflow trigger first; the webhook accepts data in the **exact same structure** as that form's fields.
</Info>

<Warning>
  **The form defines the payload contract.** The accepted fields, their types, and which ones are required come directly from the selected form's current fields. If you add, rename, or remove fields on the form, the webhook's expected payload changes immediately — update your sending system to match, or requests will start failing with `422` errors.
</Warning>

### What happens when data arrives

1. OrbitForms validates the HMAC signature and payload structure
2. A **form submission** is created (visible in your submissions list)
3. **Only that workflow** runs — other form integrations (outbound webhooks, Slack, Zapier, etc.) are **not** triggered

## Setup: from form to first request

An inbound webhook always belongs to a **workflow** whose trigger is a **form**. The form defines the payload contract, the workflow defines what happens when data arrives. Setting one up is a three-part flow: **build a form → wire up the workflow → send matching data**.

### Step 1 — Create the form

Before you can enable an inbound webhook, you need a form whose fields describe the data you'll send:

1. Go to **Forms** and create a new form
2. Add a field for every piece of data your external system will send — e.g. an email field, a first-name field, a company field
3. Note each field's **name** (not its label) — the webhook payload is keyed by field name, and names must match exactly
4. Mark fields **required** if your sender must always include them

<Tip>
  The form is only used as a data contract — it does **not** need to be published, shared, or embedded anywhere. But the endpoint refuses to enable a webhook on a form with no data fields, so add at least one field first.
</Tip>

### Step 2 — Create the workflow and enable the webhook

1. Go to **Workflows** and create a new workflow
2. Add a **Form Submission** trigger and select the form you just created
3. In the trigger panel, toggle on **Inbound Webhook**
4. Copy the **Webhook URL** and **Signing Secret** — the secret is shown once, so store it like a password
5. Add the actions the workflow should run when data arrives (create a contact, send an email, push to your CRM, etc.)
6. **Activate** the workflow — the endpoint rejects requests with `409` while the workflow is still a draft

The trigger panel displays the exact JSON structure required, keyed by each field's `name` (not label).

### Step 3 — Send data that matches the form

Build a JSON object whose keys are the form's field names. If your form looks like this:

| Form field label | Field `name` | Required |
| ---------------- | ------------ | -------- |
| Email            | `email`      | Yes      |
| First name       | `first_name` | No       |
| Company          | `company`    | No       |

…then a valid payload looks like this:

```json theme={null}
{
  "email": "jane@example.com",
  "first_name": "Jane",
  "company": "Acme Inc"
}
```

Send it with the signature headers described below, and the workflow runs once per request.

<Warning>
  **Field names must match exactly.** `first_name` and `firstName` are different fields. If you rename, add, or remove a field on the form, the expected payload changes immediately — update your sending system to match, or requests will start failing with `422` errors telling you exactly which fields are wrong.
</Warning>

## Endpoint

```
POST https://orbitforms.ai/api/webhooks/inbound/{token}
```

Each workflow gets a unique `{token}` when you enable the inbound webhook.

### Required headers

| Header              | Description                                                                    |
| ------------------- | ------------------------------------------------------------------------------ |
| `Content-Type`      | `application/json`                                                             |
| `X-Orbit-Signature` | HMAC-SHA256 signature of the **raw request body**: `sha256=<hex>`              |
| `X-Orbit-Timestamp` | *(Optional)* Unix timestamp in milliseconds — rejected if older than 5 minutes |

## Payload structure

Send a **flat JSON object** where each key is a form field `name`:

```json theme={null}
{
  "email": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "company": "Acme Inc",
  "interests": ["Product A", "Product B"]
}
```

### Field types

| Form field type                                                                  | JSON type  | Example                    |
| -------------------------------------------------------------------------------- | ---------- | -------------------------- |
| Text, email, phone, URL, textarea, number, date, time, dropdown, multiple choice | `string`   | `"jane@example.com"`       |
| Multi-select                                                                     | `string[]` | `["Option 1", "Option 2"]` |

<Warning>
  **Strict validation**

  * Only field names from the selected form — plus any [custom fields](#custom-fields) declared on the trigger — are allowed
  * Required fields must be present and non-empty
  * System keys starting with `_` are rejected
  * Unknown fields return a `422` error with details
</Warning>

## Custom fields

You can send data that isn't part of the form — a lead score, a qualification flag, an external ID — by declaring **custom fields** on the trigger:

1. Open the workflow trigger panel with the inbound webhook enabled
2. Under **Custom fields**, add a field name (e.g. `qualified`) and choose its type — **Text** (`string`) or **List** (`string[]`)
3. Save the workflow

Declared custom fields are accepted in the payload alongside the form's fields:

```json theme={null}
{
  "email": "jane@example.com",
  "first_name": "Jane",
  "qualified": "yes"
}
```

Custom field values behave exactly like form field values downstream: they're stored on the submission, can be used in **Filter** conditions (e.g. `qualified` equals `yes`), and are available to later workflow steps and field mappings.

<Note>
  * Custom fields are **always optional** — a payload that omits them is still valid
  * Names must start with a letter and contain only letters, numbers, and underscores (max 64 characters, up to 20 custom fields)
  * A custom field cannot reuse a form field's name — the form's definition wins
  * Undeclared fields are still rejected with `422`, so typos fail loudly instead of silently dropping data
  * Like form-field edits, custom-field changes take effect as soon as the workflow is **saved** (the editor auto-saves) — they do not wait for a publish, so update your sender before removing or renaming a field the sender still uses
</Note>

## Signing requests

Compute the signature over the **exact raw JSON body** (before parsing):

```javascript theme={null}
const crypto = require('crypto');

function signPayload(rawBody, secret) {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(rawBody, 'utf8');
  return `sha256=${hmac.digest('hex')}`;
}

const body = JSON.stringify({
  email: 'jane@example.com',
  first_name: 'Jane',
  last_name: 'Doe',
});

const signature = signPayload(body, process.env.ORBIT_WEBHOOK_SECRET);
const timestamp = Date.now().toString();

// Send with fetch, axios, etc.
```

```python theme={null}
import hmac
import hashlib
import json
import time

def sign_payload(raw_body: str, secret: str) -> str:
    digest = hmac.new(secret.encode(), raw_body.encode(), hashlib.sha256).hexdigest()
    return f"sha256={digest}"

body = json.dumps({"email": "jane@example.com", "first_name": "Jane"})
signature = sign_payload(body, os.environ["ORBIT_WEBHOOK_SECRET"])
timestamp = str(int(time.time() * 1000))
```

<Note>
  The signing algorithm matches [outbound webhook verification](/developers/webhooks/security). Use the same HMAC-SHA256 approach with your inbound webhook secret.
</Note>

## cURL example

Replace `{token}`, `{secret}`, and field values with your workflow's values:

```bash theme={null}
BODY='{"email":"jane@example.com","first_name":"Jane","last_name":"Doe"}'
TIMESTAMP=$(date +%s000)
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "YOUR_SECRET" | sed 's/^.* //')
SIGNATURE="sha256=$SIGNATURE"

curl -X POST "https://orbitforms.ai/api/webhooks/inbound/{token}" \
  -H "Content-Type: application/json" \
  -H "X-Orbit-Signature: $SIGNATURE" \
  -H "X-Orbit-Timestamp: $TIMESTAMP" \
  -d "$BODY"
```

## Responses

### Success (`200`)

```json theme={null}
{
  "success": true,
  "submission_id": "550e8400-e29b-41d4-a716-446655440000",
  "workflow_id": "660e8400-e29b-41d4-a716-446655440001"
}
```

### Error codes

| Status | Meaning                                                                                                                                     |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Missing or invalid `X-Orbit-Signature`                                                                                                      |
| `404`  | Webhook token not found or disabled                                                                                                         |
| `409`  | Workflow is not active — activate the workflow before sending data. (A **paused** workflow accepts data and queues it to run when resumed.) |
| `413`  | Request body exceeds 1 MB                                                                                                                   |
| `422`  | Payload validation failed — see `details` array                                                                                             |
| `429`  | Rate limit exceeded (100 requests/minute per team)                                                                                          |
| `500`  | Server error                                                                                                                                |

Example validation error:

```json theme={null}
{
  "error": "Payload validation failed",
  "details": [
    "Required field \"email\" is missing or empty",
    "Unknown field \"unknown_field\""
  ]
}
```

## Rotating the signing secret

In the workflow trigger panel, use **Rotate secret** to generate a new signing secret. Update your external system immediately — requests signed with the old secret will fail.

## Disabling the webhook

Toggle off **Inbound Webhook** in the trigger panel, or delete the workflow. The URL becomes invalid immediately.

## Related

<CardGroup cols={2}>
  <Card title="Outbound Webhooks" icon="arrow-right-from-bracket" href="/developers/webhooks/overview">
    Receive events when forms are submitted (Orbit → your server)
  </Card>

  <Card title="Webhook Security" icon="shield-halved" href="/developers/webhooks/security">
    HMAC signature verification details
  </Card>

  <Card title="Workflows Guide" icon="bolt" href="/guides/workflows">
    Build and manage automation workflows
  </Card>

  <Card title="Form Fields" icon="input-text" href="/guides/form-fields">
    Understand field names and types
  </Card>
</CardGroup>
