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

# Webhooks

> Receive signed HTTP notifications when packages and signing sessions change.

A webhook endpoint is an HTTPS URL that FlowSign POSTs to when something happens to a package or a recipient's session. Endpoints belong to the organisation and receive events from every workspace.

<Info>
  Webhooks are included in the Enterprise plan. Managing endpoints, in the app or through the API, requires an organisation admin.
</Info>

## Creating an endpoint

### In the app

Open **Settings > Webhooks** ([my.flowsign.app/settings/webhooks](https://my.flowsign.app/settings/webhooks)) and add an endpoint:

* **Endpoint URL**: must use `https://`.
* **Description**: optional, for your own reference.
* **Events**: tick the events to subscribe to. See [Events](/webhooks/events).

After saving, the endpoint's **signing secret** is shown once. Store it; you need it to verify deliveries and it cannot be read again.

The same page lists each endpoint with its state (**Active**, **Paused** or **Failing**), lets you pause, resume, edit or delete it, and opens a delivery log you can search and filter by outcome.

### With the API

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://my.flowsign.app/api/v1/webhooks \
    -H "Authorization: Bearer fsk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://api.example.com/webhooks/flowsign",
      "events": ["PACKAGE_COMPLETED", "SESSION_DECLINED"],
      "description": "Production"
    }'
  ```

  ```javascript JavaScript theme={null}
  const res = await fetch("https://my.flowsign.app/api/v1/webhooks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FLOWSIGN_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url: "https://api.example.com/webhooks/flowsign",
      events: ["PACKAGE_COMPLETED", "SESSION_DECLINED"],
      description: "Production",
    }),
  });

  const { data } = await res.json();
  console.log(data.secret);
  ```

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

  import requests

  res = requests.post(
      "https://my.flowsign.app/api/v1/webhooks",
      headers={"Authorization": f"Bearer {os.environ['FLOWSIGN_API_KEY']}"},
      json={
          "url": "https://api.example.com/webhooks/flowsign",
          "events": ["PACKAGE_COMPLETED", "SESSION_DECLINED"],
          "description": "Production",
      },
      timeout=30,
  )
  res.raise_for_status()

  print(res.json()["data"]["secret"])
  ```

  ```csharp C# theme={null}
  using System.Net.Http.Headers;
  using System.Net.Http.Json;
  using System.Text.Json;

  using var http = new HttpClient { BaseAddress = new Uri("https://my.flowsign.app") };
  http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
      "Bearer",
      Environment.GetEnvironmentVariable("FLOWSIGN_API_KEY"));

  var res = await http.PostAsJsonAsync("/api/v1/webhooks", new
  {
      url = "https://api.example.com/webhooks/flowsign",
      events = new[] { "PACKAGE_COMPLETED", "SESSION_DECLINED" },
      description = "Production",
  });
  res.EnsureSuccessStatusCode();

  var body = await res.Content.ReadFromJsonAsync<JsonElement>();
  Console.WriteLine(body.GetProperty("data").GetProperty("secret").GetString());
  ```
</CodeGroup>

```json theme={null}
{
  "data": {
    "id": "whk_4d2a",
    "url": "https://api.example.com/webhooks/flowsign",
    "secret": "3f9c...e1",
    "events": ["PACKAGE_COMPLETED", "SESSION_DECLINED"],
    "enabled": true,
    "createdAt": "2026-09-17T01:12:44.000Z"
  }
}
```

`secret` is returned only in this response. `GET`, `PATCH` and `DELETE /api/v1/webhooks/{endpointId}` manage the endpoint afterwards; `GET` also returns its 25 most recent deliveries.

## What gets delivered

Each delivery is an HTTP `POST` with a JSON body and three headers:

| Header                   | Value                                                                      |
| ------------------------ | -------------------------------------------------------------------------- |
| `X-FlowSign-Event`       | The event name, for example `PACKAGE_COMPLETED`                            |
| `X-FlowSign-Delivery-Id` | A stable id for this delivery. Retries reuse it, so use it to deduplicate. |
| `X-FlowSign-Signature`   | Hex-encoded HMAC-SHA256 of the raw body, keyed with the endpoint secret    |

The body has the same envelope for every event:

```json theme={null}
{
  "event": "PACKAGE_COMPLETED",
  "timestamp": "2026-09-17T01:15:02.318Z",
  "data": {
    "packageId": "pkg_9f3c2e",
    "packageTitle": "Employment agreement: Ana Reid",
    "completedAt": "2026-09-17T01:15:02.301Z",
    "metadata": { "employee_id": "E-1042" }
  }
}
```

`data` differs per event; see [Events](/webhooks/events). Every event's `data` carries `metadata`, the package's custom field values.

Respond with any `2xx` status within **10 seconds**. Do the real work after you have responded; anything slower is treated as a failure and retried.

## Verifying a delivery

Compute an HMAC-SHA256 of the **raw request body** (before any JSON parsing or re-serialisation) with the endpoint secret, hex-encode it, and compare it to `X-FlowSign-Signature` with a constant-time comparison.

<CodeGroup>
  ```javascript JavaScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";
  import express from "express";

  const app = express();
  const SECRET = process.env.FLOWSIGN_WEBHOOK_SECRET;

  app.post(
    "/webhooks/flowsign",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const expected = createHmac("sha256", SECRET).update(req.body).digest("hex");
      const received = req.get("X-FlowSign-Signature") ?? "";

      const valid =
        expected.length === received.length &&
        timingSafeEqual(Buffer.from(expected), Buffer.from(received));

      if (!valid) return res.status(401).end();

      res.status(204).end();

      const payload = JSON.parse(req.body.toString("utf8"));
      handleEvent(req.get("X-FlowSign-Delivery-Id"), payload);
    },
  );
  ```

  ```python Python theme={null}
  import hmac
  import os
  from hashlib import sha256

  from flask import Flask, request

  app = Flask(__name__)
  SECRET = os.environ["FLOWSIGN_WEBHOOK_SECRET"].encode()


  @app.post("/webhooks/flowsign")
  def flowsign_webhook():
      expected = hmac.new(SECRET, request.get_data(), sha256).hexdigest()
      received = request.headers.get("X-FlowSign-Signature", "")

      if not hmac.compare_digest(expected, received):
          return "", 401

      enqueue_event(request.headers["X-FlowSign-Delivery-Id"], request.get_json())
      return "", 204
  ```

  ```csharp C# theme={null}
  using System.Security.Cryptography;
  using System.Text;
  using System.Text.Json;

  var builder = WebApplication.CreateBuilder(args);
  var app = builder.Build();

  var secret = Encoding.UTF8.GetBytes(builder.Configuration["FLOWSIGN_WEBHOOK_SECRET"]!);

  app.MapPost("/webhooks/flowsign", async (HttpRequest req) =>
  {
      using var buffer = new MemoryStream();
      await req.Body.CopyToAsync(buffer);
      var raw = buffer.ToArray();

      var expected = Convert.ToHexString(HMACSHA256.HashData(secret, raw)).ToLowerInvariant();
      var received = req.Headers["X-FlowSign-Signature"].ToString();

      var valid = CryptographicOperations.FixedTimeEquals(
          Encoding.UTF8.GetBytes(expected),
          Encoding.UTF8.GetBytes(received));

      if (!valid) return Results.Unauthorized();

      var payload = JsonSerializer.Deserialize<JsonElement>(raw);
      EnqueueEvent(req.Headers["X-FlowSign-Delivery-Id"].ToString(), payload);

      return Results.NoContent();
  });

  app.Run();
  ```
</CodeGroup>

The Python and C# receivers hand the payload to a background queue rather than processing it inline, so the response still goes out well inside the 10 second budget.

Use `X-FlowSign-Delivery-Id` as an idempotency key: a delivery can arrive more than once if your endpoint responded slowly the first time.

## Retries

If your endpoint returns a non-`2xx` status, times out, or cannot be reached, the delivery is retried with exponential backoff. The delays below are approximate: the queue adds random jitter to each one.

| Attempt | Approximate delay after the previous attempt |
| ------- | -------------------------------------------- |
| 2       | 1 minute                                     |
| 3       | 2 minutes                                    |
| 4       | 4 minutes                                    |
| 5       | 8 minutes                                    |
| 6       | 16 minutes                                   |
| 7       | 32 minutes                                   |
| 8       | 64 minutes                                   |
| 9       | 128 minutes                                  |

After nine attempts, spread over roughly four hours, the delivery is marked **exhausted** and not retried.

Each failed attempt increments the endpoint's consecutive failure counter; a successful delivery resets it to zero. At **10 consecutive failures** the endpoint is shown as **Failing** and is skipped for new events until you re-enable it. Resuming the endpoint in Settings, or `PATCH /api/v1/webhooks/{endpointId}` with `{ "enabled": true }`, clears the counter.

Pausing an endpoint stops deliveries without counting failures. Deliveries already queued for a paused endpoint are dropped, not retried.

## Delivery log

Every delivery is recorded with its event, attempt count, the response status and body (first 1,000 characters) and any error. Read it from the deliveries panel in **Settings > Webhooks**, or from `GET /api/v1/webhooks/{endpointId}`, which returns the 25 most recent. There is no manual redelivery; if a delivery is exhausted, fetch the package with `GET /api/v1/packages/{packageId}` to catch up.
