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

# Webhooks

> Trigger deployed workflows from external systems via signed HTTP calls.

Webhooks let external systems (Stripe, GitHub, form submissions, internal services) trigger a deployed workflow over HTTP. The webhook ID and secret are minted when you arm a workflow for webhook execution. See the [workflow guide](/guides/workflows-programmatic) for the full deploy + arm flow.

The trigger endpoint is **unauthenticated by design** (no `apikey` / `Authorization: Bearer {API_KEY}` headers needed). Authentication is per-webhook: the secret token returned by the arm step must be presented either in an `Authorization: Bearer <secret>` header or as a `?token=<secret>` query parameter.

## Common Patterns

A workflow's webhook lives in one of two states:

* **Deployed**: the webhook is permanently active and accepts unlimited calls.
* **Armed for single use**: the webhook accepts exactly one call within the arm window; after firing, you must re-arm. This suits one-shot integrations (testing, manual triggers) where you don't want a long-lived endpoint.

The trigger endpoint behaves identically in both modes. The difference is only in how the workflow was prepared.

### POST /api/webhooks/{webhook_id}

Trigger a workflow. The request body becomes the workflow's input variables. Returns the execution outcome synchronously (the workflow runs inline with a 5-minute timeout).

<ParamField path="webhook_id" type="string" required>
  The webhook ID returned when the workflow was armed/deployed. Must be a valid UUID.
</ParamField>

<ParamField header="Authorization" type="string">
  `Bearer <webhook_secret>` — preferred over the query-param form.
</ParamField>

<ParamField query="token" type="string">
  Webhook secret. Use this when you can't set headers (e.g., a webhook source that only supports a URL).
</ParamField>

<Note>
  Use one auth mechanism or the other (Bearer header OR `?token=` query). The server checks the header with `auth_header.lower().startswith("bearer ")` (note the trailing space). If your `Authorization` header is exactly `Bearer ` (trailing space, no token), which is what `Bearer ${secret || ""}` produces when `secret` is falsy, the server reads an empty token and returns 401 without consulting `?token=`. `Bearer` with no trailing space falls through to the query param.
</Note>

<RequestExample>
  ```json Body theme={null}
  {
    "customer_email": "user@example.com",
    "amount_cents": 4900
  }
  ```
</RequestExample>

<ResponseExample>
  ```json 200 — success theme={null}
  {
    "execution_id": "exec-uuid",
    "status": "completed",
    "output": { "summary": "..." }
  }
  ```

  ```json 504 — timeout theme={null}
  {
    "error": "Execution timed out after 300s",
    "code": "execution_timeout",
    "execution_id": "exec-uuid"
  }
  ```
</ResponseExample>

<CodeGroup>
  ```python Python theme={null}
  # Header auth (recommended)
  response = requests.post(
      f"{BASE_URL}/api/webhooks/{webhook_id}",
      headers={"Authorization": f"Bearer {webhook_secret}", "Content-Type": "application/json"},
      json={"customer_email": "user@example.com"},
  )

  # Or query-param auth (when headers aren't an option)
  response = requests.post(
      f"{BASE_URL}/api/webhooks/{webhook_id}?token={webhook_secret}",
      json={"customer_email": "user@example.com"},
  )
  ```

  ```typescript TypeScript theme={null}
  await fetch(`${BASE_URL}/api/webhooks/${webhookId}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${webhookSecret}`, "Content-Type": "application/json" },
    body: JSON.stringify({ customer_email: "user@example.com" }),
  });
  ```

  ```bash cURL theme={null}
  curl -X POST '{BASE_URL}/api/webhooks/{webhook_id}' \
    -H "Authorization: Bearer {webhook_secret}" \
    -H "Content-Type: application/json" \
    -d '{"customer_email": "user@example.com"}'
  ```
</CodeGroup>

<Note>
  The trigger endpoint executes the workflow inline and returns the final output. For long-running workflows that exceed the 5-minute synchronous limit, design the workflow to acknowledge the request quickly and continue work asynchronously (e.g. dispatch via a `general_api` block to a background processor).
</Note>

## Security model

What the trigger endpoint does and doesn't do. Webhook security is where unstated assumptions get exploited, so the guarantees are spelled out below.

**What it does:**

* **Verifies the secret via constant-time comparison.** `hmac.compare_digest` on the server side prevents timing-attack style secret guessing.
* **Validates the secret BEFORE the deploy-or-arm state gate.** An invalid secret cannot consume the single-use arm slot, so bad requests don't accidentally disarm a workflow before legitimate ones can fire.
* **Atomic single-use disarm.** For armed (single-use) workflows, the platform issues a `RETURNING id` UPDATE that disarms in the same statement. Only the first of N concurrent requests with a valid secret wins; the rest see `webhook_armed_until = NULL` and return `403`.

**What it does NOT do:**

* **No HMAC of the request body.** The webhook secret authenticates the *caller*, not the request. A man-in-the-middle who can read the URL or `Authorization` header can replay the request with any body. **For sensitive triggers, use TLS termination at your network boundary and a trusted client.** Don't rely on the webhook itself to prove the body was authored by the upstream system.
* **No timestamp / nonce / replay-window protection.** The secret doesn't expire and isn't tied to a specific request. An attacker who captures one valid request can replay it until you rotate the secret.
* **No retry / redelivery.** A failed trigger is gone; the trigger endpoint is fire-and-forget from the upstream's perspective. If you need at-least-once delivery semantics, configure your upstream system's own retry policy.
* **No request idempotency.** Two identical calls (same body, same time) trigger two executions. Pair with workflow logic that's idempotent if you need that.

**The 10-minute arm TTL is the closest thing to a replay window.** A captured request to an armed (vs deployed) webhook has at most 10 minutes from arming to be replayed. Deployed webhooks have no such window.

For upstream systems that support body signatures (Stripe, GitHub, etc.), do the signature check on your end inside the workflow's first block (a `code` block validating the signature, branching to `response` on failure). The platform won't do it for you.

## Error Responses

Errors return `{"error": "<message>", "error_code": "<UPPER_SNAKE_CODE>"}`. The 401 responses additionally omit `error_code` (they're a plain `{"error": "Unauthorized"}`).

| Status | `error_code`         | Description                                                                        |
| ------ | -------------------- | ---------------------------------------------------------------------------------- |
| 400    | `VALIDATION_ERROR`   | `webhook_id` is not a valid UUID                                                   |
| 401    | —                    | Missing or incorrect webhook secret                                                |
| 403    | —                    | Webhook is not active (workflow not deployed and no live arm token)                |
| 404    | `WORKFLOW_NOT_FOUND` | No workflow has a webhook block with this ID                                       |
| 500    | `EXECUTION_FAILED`   | The workflow ran but raised an error. The `execution_id` is included for debugging |
| 504    | `EXECUTION_TIMEOUT`  | The workflow exceeded the 5-minute synchronous limit                               |
