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

# Webhooks

> Register endpoints to receive real-time event notifications from FormFlows.ai.

Webhooks let you receive HTTP notifications whenever something happens in FormFlows.ai — a form is published, a submission is created, or a submission is updated. When an event occurs, FormFlows.ai sends a `POST` request with a JSON payload to your registered URL.

***

## Register a webhook

`POST /webhooks`

Registers a new webhook endpoint.

### Body parameters

<ParamField body="url" type="string" required>
  The HTTPS URL that FormFlows.ai will send event payloads to. Must be publicly accessible.
</ParamField>

<ParamField body="events" type="string[]" required>
  Array of event types to subscribe to. Supported events:

  * `submission.created` — A new submission is received on a form.
  * `submission.updated` — An existing submission is modified.
  * `form.published` — A form's status changes to `published`.
</ParamField>

<ParamField body="form_id" type="string">
  Restrict this webhook to events from a specific form. Omit to receive events from all forms in your account.
</ParamField>

<ParamField body="secret" type="string">
  A signing secret you choose. If provided, FormFlows.ai signs every payload with HMAC-SHA256 using this secret and includes the signature in the `X-FormFlows-Signature` header. Use this to verify that payloads genuinely come from FormFlows.ai.
</ParamField>

### Response

<ResponseField name="id" type="string">
  Unique webhook identifier, prefixed with `whk_`.
</ResponseField>

<ResponseField name="url" type="string">
  The registered endpoint URL.
</ResponseField>

<ResponseField name="events" type="string[]">
  List of event types this webhook is subscribed to.
</ResponseField>

<ResponseField name="form_id" type="string">
  The form this webhook is scoped to. `null` if it receives events from all forms.
</ResponseField>

<ResponseField name="created_at" type="string">
  ISO 8601 creation timestamp.
</ResponseField>

<CodeGroup>
  ```bash curl theme={null}
  curl --request POST \
    --url "https://api.formflows.ai/v1/webhooks" \
    --header "Authorization: Bearer sk_live_your_api_key" \
    --header "Content-Type: application/json" \
    --data '{
      "url": "https://hooks.yourapp.com/formflows",
      "events": ["submission.created", "form.published"],
      "form_id": "frm_8kQmP2xNvL",
      "secret": "whsec_your_signing_secret"
    }'
  ```

  ```python Python theme={null}
  import requests

  payload = {
      "url": "https://hooks.yourapp.com/formflows",
      "events": ["submission.created", "form.published"],
      "form_id": "frm_8kQmP2xNvL",
      "secret": "whsec_your_signing_secret",
  }

  response = requests.post(
      "https://api.formflows.ai/v1/webhooks",
      headers={"Authorization": "Bearer sk_live_your_api_key"},
      json=payload,
  )

  webhook = response.json()
  print("Registered webhook:", webhook["id"])
  ```
</CodeGroup>

**Example response:**

```json theme={null}
{
  "id": "whk_6mNoP8qRsT",
  "url": "https://hooks.yourapp.com/formflows",
  "events": ["submission.created", "form.published"],
  "form_id": "frm_8kQmP2xNvL",
  "secret_set": true,
  "created_at": "2025-01-15T12:00:00Z"
}
```

<Note>
  The `secret` value you provide is never returned in API responses. The `secret_set` boolean confirms whether a signing secret is configured.
</Note>

***

## List webhooks

`GET /webhooks`

Returns all registered webhooks in your account.

### Query parameters

<ParamField query="limit" type="integer" default="20">
  Number of webhooks to return per page. Maximum `100`.
</ParamField>

<ParamField query="cursor" type="string">
  Pagination cursor from a previous response's `next_cursor`.
</ParamField>

<CodeGroup>
  ```bash curl theme={null}
  curl --request GET \
    --url "https://api.formflows.ai/v1/webhooks" \
    --header "Authorization: Bearer sk_live_your_api_key"
  ```

  ```python Python theme={null}
  import requests

  response = requests.get(
      "https://api.formflows.ai/v1/webhooks",
      headers={"Authorization": "Bearer sk_live_your_api_key"},
  )

  result = response.json()
  for webhook in result["data"]:
      print(webhook["id"], webhook["url"], webhook["events"])
  ```
</CodeGroup>

***

## Delete a webhook

`DELETE /webhooks/{id}`

Removes a registered webhook. FormFlows.ai will stop sending events to that endpoint immediately.

### Path parameters

<ParamField path="id" type="string" required>
  The webhook ID to delete, e.g. `whk_6mNoP8qRsT`.
</ParamField>

### Response

Returns a `204 No Content` response on success.

<CodeGroup>
  ```bash curl theme={null}
  curl --request DELETE \
    --url "https://api.formflows.ai/v1/webhooks/whk_6mNoP8qRsT" \
    --header "Authorization: Bearer sk_live_your_api_key"
  ```

  ```python Python theme={null}
  import requests

  response = requests.delete(
      "https://api.formflows.ai/v1/webhooks/whk_6mNoP8qRsT",
      headers={"Authorization": "Bearer sk_live_your_api_key"},
  )

  if response.status_code == 204:
      print("Webhook deleted.")
  ```
</CodeGroup>

***

## Webhook payload format

When a subscribed event occurs, FormFlows.ai sends a `POST` request to your URL with the following JSON body:

```json theme={null}
{
  "event": "submission.created",
  "form_id": "frm_8kQmP2xNvL",
  "timestamp": "2025-01-15T14:32:00Z",
  "data": {
    "submission_id": "sub_7vHnM4rKpQ",
    "fields": {
      "fld_a1B2c3D4": "Jane Smith",
      "fld_e5F6g7H8": "jane.smith@example.com",
      "fld_i9J0k1L2": ["Keynote", "Workshop A"]
    }
  }
}
```

### Payload fields

<ResponseField name="event" type="string">
  The event type that fired. One of `submission.created`, `submission.updated`, or `form.published`.
</ResponseField>

<ResponseField name="form_id" type="string">
  The ID of the form that the event is associated with.
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO 8601 timestamp of when the event occurred.
</ResponseField>

<ResponseField name="data" type="object">
  Event-specific data.

  <Expandable title="data properties for submission.created / submission.updated">
    <ResponseField name="submission_id" type="string">
      The ID of the submission that was created or updated.
    </ResponseField>

    <ResponseField name="fields" type="object">
      Key-value map of field IDs to submitted values.
    </ResponseField>
  </Expandable>

  <Expandable title="data properties for form.published">
    <ResponseField name="name" type="string">
      The name of the form that was published.
    </ResponseField>

    <ResponseField name="published_at" type="string">
      ISO 8601 timestamp of when the form was published.
    </ResponseField>
  </Expandable>
</ResponseField>

***

## Signature verification

If you set a `secret` when registering your webhook, every request includes an `X-FormFlows-Signature` header:

```
X-FormFlows-Signature: sha256=3d2f8c1e9b4a...
```

The value is `sha256=` followed by the hex-encoded HMAC-SHA256 of the raw request body, computed using your secret as the key. Always verify this signature before processing a payload to confirm it came from FormFlows.ai.

<Warning>
  Compare signatures using a constant-time comparison function. Using a regular string equality check (`==`) is vulnerable to timing attacks.
</Warning>

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac

  def verify_signature(payload_body: bytes, signature_header: str, secret: str) -> bool:
      """Verify the X-FormFlows-Signature header against the request body."""
      expected_sig = hmac.new(
          secret.encode("utf-8"),
          payload_body,
          hashlib.sha256,
      ).hexdigest()

      received_sig = signature_header.removeprefix("sha256=")

      return hmac.compare_digest(expected_sig, received_sig)


  # Example usage in a Flask handler
  from flask import Flask, request, abort

  app = Flask(__name__)
  WEBHOOK_SECRET = "whsec_your_signing_secret"

  @app.route("/formflows", methods=["POST"])
  def handle_webhook():
      signature = request.headers.get("X-FormFlows-Signature", "")

      if not verify_signature(request.get_data(), signature, WEBHOOK_SECRET):
          abort(400, "Invalid signature")

      event = request.json
      print(f"Received event: {event['event']}")
      return "", 200
  ```

  ```javascript Node.js theme={null}
  const crypto = require("crypto");

  function verifySignature(rawBody, signatureHeader, secret) {
    const expectedSig = crypto
      .createHmac("sha256", secret)
      .update(rawBody)
      .digest("hex");

    const receivedSig = signatureHeader.replace("sha256=", "");

    // Use timingSafeEqual to prevent timing attacks
    return crypto.timingSafeEqual(
      Buffer.from(expectedSig, "hex"),
      Buffer.from(receivedSig, "hex")
    );
  }

  // Example usage in an Express handler
  const express = require("express");
  const app = express();
  const WEBHOOK_SECRET = "whsec_your_signing_secret";

  app.post("/formflows", express.raw({ type: "application/json" }), (req, res) => {
    const signature = req.headers["x-formflows-signature"] || "";

    if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
      return res.status(400).send("Invalid signature");
    }

    const event = JSON.parse(req.body);
    console.log("Received event:", event.event);
    res.status(200).send();
  });
  ```
</CodeGroup>

***

## Retry policy

FormFlows.ai considers a delivery successful when your endpoint returns any `2xx` HTTP status code within 10 seconds. If the request times out or returns a non-`2xx` status, FormFlows.ai retries the delivery with exponential backoff:

| Attempt     | Delay      |
| ----------- | ---------- |
| 1 (initial) | Immediate  |
| 2           | 30 seconds |
| 3           | 5 minutes  |
| 4           | 30 minutes |
| 5           | 2 hours    |

After 5 failed attempts, the event is marked as undeliverable and no further retries occur.

<Warning>
  Your endpoint must respond within **10 seconds**. If your processing logic takes longer than that, return a `200` immediately and handle the payload asynchronously (e.g., push it to a queue).
</Warning>

<Tip>
  Because retries can deliver the same event more than once, make your handler idempotent. Use `data.submission_id` or `timestamp` to detect and skip duplicate deliveries.
</Tip>
