# Webhook actions

Receive an HTTP call from a workflow and feed its response back into the next node.

`actions.http_webhook` calls your API from inside a workflow. The response is
parsed and exposed as node outputs, so the next node can branch on your own
service's answer.

Here is the endpoint that receives it:

```typescript
// app/api/buildbase/provision/route.ts
export async function POST(request: Request) {
  const secret = request.headers.get('x-webhook-secret');
  if (secret !== process.env.WORKFLOW_WEBHOOK_SECRET) {
    return Response.json({ error: 'forbidden' }, { status: 403 });
  }

  const { workspaceId, plan } = await request.json();

  const account = await provisionAccount(workspaceId, plan);

  // Everything returned here is available to later nodes as
  // {{responseBody.<key>}}
  return Response.json({
    accountId: account.id,
    region: account.region,
    seatsRemaining: account.seatsRemaining,
  });
}
```

> **Note:**
  Add the HTTP Webhook action in the console's workflow editor, set the URL, and
  add your shared secret under **Headers**. Header values accept merge tags.


## Configuration

| Input     | Type      | Default   | Notes                                                      |
| --------- | --------- | --------- | ---------------------------------------------------------- |
| `url`     | text      | Required  | Merge tags allowed, so the path can include a workspace ID |
| `method`  | select    | `POST`    | **`POST`, `PUT`, `PATCH` only**                            |
| `headers` | key-value | —         | Merge tags allowed in values                               |
| `body`    | JSON      | —         | Merge tags allowed with `{{key}}`                          |
| `timeout` | number    | 30,000 ms | Range 1,000 – 120,000 ms                                   |

The console's method dropdown offers only `POST`, `PUT`, and `PATCH`. The action
is built for sending data, so a read-only lookup against your API needs a `POST`
endpoint on your side.

`Content-Type: application/json` is set for you. A string `body` that parses as
JSON is sent as JSON; one that does not is sent as-is.

### Constraints you cannot configure away

| Constraint                    | Value                                                                                    |
| ----------------------------- | ---------------------------------------------------------------------------------------- |
| Request and response body cap | 5 MB each                                                                                |
| Response stored on the node   | Truncated to 4,096 characters                                                            |
| Blocked destinations          | `localhost`, `127.x`, `10.x`, `172.16–31.x`, `192.168.x`, `169.254.x`, `0.0.0.0`, `::1`  |
| Stripped request headers      | `host`, `authorization`, `cookie`, `x-forwarded-for`, `x-real-ip`, `proxy-authorization` |

> **Note:**
  **`authorization` is silently stripped.** Putting your credential in an
  `Authorization` header will not work — it is removed before the request is
  sent, with no error. Use a custom header name such as `x-webhook-secret`, as
  the example above does.


Destinations are checked twice: the hostname is matched against the blocked
list, then resolved and its IP rejected if it lands in a private range. That
second check defeats DNS rebinding, and it also means a workflow cannot call a
service on your private network — the endpoint must be publicly reachable.

## Outputs

| Output            | Type    | Description                  |
| ----------------- | ------- | ---------------------------- |
| `statusCode`      | number  | HTTP status returned         |
| `responseBody`    | object  | Parsed response body         |
| `responseHeaders` | object  | Response headers             |
| `success`         | boolean | Whether the status was 2xx   |
| `duration`        | number  | Round-trip time in ms        |
| `requestedAt`     | string  | ISO timestamp of the request |

Reference them downstream as `{{responseBody.accountId}}`, `{{statusCode}}`, and
so on.

Branch on `success` rather than on `statusCode` unless you care about a specific
code — `success` is already the 2xx test.

## Outputs

| Output            | Type    | Description                  |
| ----------------- | ------- | ---------------------------- |
| `statusCode`      | number  | HTTP status returned         |
| `responseBody`    | object  | Parsed response body         |
| `responseHeaders` | object  | Response headers             |
| `success`         | boolean | Whether the status was 2xx   |
| `duration`        | number  | Round-trip time in ms        |
| `requestedAt`     | string  | ISO timestamp of the request |

Reference them downstream as `{{responseBody.accountId}}`, `{{statusCode}}`, and
so on.

Branch on `success` rather than on `statusCode` unless you care about a specific
code — `success` is already the 2xx test.

## What counts as a failure

This is the part worth reading twice, because it is not what most people
assume.

| Outcome                                    | Node result                                       | Retried? |
| ------------------------------------------ | ------------------------------------------------- | -------- |
| `2xx`                                      | Completes, `success: true`                        | —        |
| `4xx` or `5xx`                             | **Completes**, `success: false`, `statusCode` set | **No**   |
| Timeout                                    | Throws                                            | Yes      |
| DNS failure, connection refused, TLS error | Throws                                            | Yes      |
| Blocked host or private IP                 | Throws                                            | Yes      |

**A non-2xx response does not fail the node.** The request is treated as having
happened — the status is simply recorded and the flow continues down the normal
path with `success: false`. Retries exist for requests that never got an
answer, not for answers you did not like.

Two consequences:

- Returning `409` for "already processed" is fine. It will not be retried and
  it will not fail the instance. Branch on `success` or `statusCode` instead.
- If you _want_ a bad status to stop the flow, add an `if_else` condition on
  `{{success}}` after the webhook node. Nothing does this for you.

## Retries

The retry policy applies only to the throwing cases above. This action
overrides the workflow defaults, because outbound HTTP is the least reliable
thing a workflow does:

| Setting        | HTTP webhook  | Workflow default |
| -------------- | ------------- | ---------------- |
| `maxAttempts`  | **5**         | 3                |
| `backoffType`  | exponential   | exponential      |
| `backoffDelay` | **10,000 ms** | 5,000 ms         |
| `timeoutMs`    | **60,000 ms** | 30,000 ms        |

Note the two timeouts are different things: the `timeout` **input** (default
30s) bounds a single request, while `timeoutMs` in the retry policy (60s)
bounds the attempt from the queue's point of view.

> **Note:**
  Five attempts with exponential backoff means **your endpoint must be
  idempotent**. A request that times out after doing its work is retried, and
  the workflow has no way to know the first attempt succeeded. Key your writes
  on something stable from the payload.


## Testing

Running the workflow with `/test` in `dry-run` mode does **not** call your
endpoint. The node logs what it would have sent and returns placeholder
outputs. Use `hot-run` to exercise the real request.

## Verifying the request

The action sends **no signature** — there is no HMAC header and no shared
signing secret, unlike [webhooks](/webhooks/overview), which do sign their
deliveries.

Authenticate the call yourself:

- Put a long random secret in a custom header, as the example above does, and
  compare it in constant time.
- Or embed an unguessable token in the URL path.

Do not rely on source IP. Workflow execution runs from the BuildBase workers and
the address is not contractual.

## Sending workflow context

The body is JSON with merge tags, so pass whatever the receiving service needs:

```json
{
  "workspaceId": "{{workspaceId}}",
  "plan": "{{subscription.planName}}",
  "triggeredBy": "{{trigger.id}}"
}
```

Merge tag references are validated at publish time. A tag pointing at a node
that no longer exists blocks the publish rather than silently sending `null`.

## When the request never lands

After the fifth failed attempt — timeout, DNS, refused connection — the node
fails. The instance then follows its error path if one is wired, or fails
outright if not. Failed instances stay visible in the console and can be
retried per node, so a fix on your side does not mean re-running the whole
flow. See [monitoring](/workflows/monitoring).

## Next Steps

- [Monitoring](/workflows/monitoring) — retry a failed node.
- [Triggers and actions](/workflows/triggers-and-actions) — the full catalog.
- [Webhooks & events](/webhooks/overview) — signed outbound events, no workflow needed.
