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

> ## Agent Instructions
> Flowsign is one word with a lowercase s.
> The REST API base URL is https://my.flowsign.app and every endpoint lives under /api/v1.
> When answering API questions, cite the HTTP method and endpoint path.
> API access needs the Enterprise plan and an API key with the API access permission.

# 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. An endpoint belongs to the workspace it was created in and receives events only for that workspace's packages. A workspace holds up to **20** endpoints.

<Info>
  Webhooks are included in the Enterprise plan. Managing endpoints, in the app or through the API, requires the manage webhooks permission, which owners and the Admin role hold.
</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://` and point at a publicly reachable host. `localhost`, `.local` and `.internal` names, and private, loopback, link-local and multicast addresses are refused.
* **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. If it is lost or exposed, rotate it (see [Rotating the secret](#rotating-the-secret)).

The same page lists each endpoint with its state (**Active**, **Paused** or **Failing**) and lets you pause, resume, edit or delete it, rotate its secret, send it a test event, and open its delivery log.

### 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": "cmg1h7t2k0003v8p9d4q6xw2e",
    "url": "https://api.example.com/webhooks/flowsign",
    "secret": "whsec_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 deliveries a page at a time. `GET /api/v1/webhooks` lists the workspace's endpoints. Creating a 21st endpoint in a workspace returns `409`.

## What gets delivered

Each delivery is an HTTP `POST` with a JSON body (`Content-Type: application/json`) and four Flowsign 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-timestamp`   | When this attempt was signed, in Unix seconds. Every attempt, retries included, gets a fresh one.                                                                                                 |
| `x-flowsign-signature`   | `v1=` followed by the hex HMAC-SHA256 of the timestamp, a `.`, and the raw body, keyed with the endpoint secret. During a secret rotation it carries one `v1=` entry per secret, comma-separated. |

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. Redirects are not followed, so a `3xx` response also counts as a failure.

## Verifying a delivery

Build the signed content from the `x-flowsign-timestamp` header, a `.`, and the **raw request body** (before any JSON parsing or re-serialisation). Compute its HMAC-SHA256 with the endpoint secret, hex-encode it, and compare it with a constant-time comparison to each `v1=` entry in `x-flowsign-signature`. Accept the delivery when any entry matches and the timestamp is within **5 minutes** of your clock; refusing older timestamps stops a captured delivery being replayed.

<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 timestamp = req.get("x-flowsign-timestamp") ?? "";
      const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
      const expected = Buffer.from(
        createHmac("sha256", SECRET).update(`${timestamp}.`).update(req.body).digest("hex"),
      );
      const valid =
        fresh &&
        (req.get("x-flowsign-signature") ?? "")
          .split(",")
          .map((entry) => entry.trim())
          .filter((entry) => entry.startsWith("v1="))
          .some((entry) => {
            const received = Buffer.from(entry.slice(3));
            return received.length === expected.length && timingSafeEqual(received, expected);
          });

      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
  import time
  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():
      timestamp = request.headers.get("x-flowsign-timestamp", "")
      if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
          return "", 401

      signed = timestamp.encode() + b"." + request.get_data()
      expected = hmac.new(SECRET, signed, sha256).hexdigest()
      received = [
          entry.strip()[3:]
          for entry in request.headers.get("x-flowsign-signature", "").split(",")
          if entry.strip().startswith("v1=")
      ]

      if not any(hmac.compare_digest(expected, candidate) for candidate in 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 timestamp = req.Headers["x-flowsign-timestamp"].ToString();
      if (!long.TryParse(timestamp, out var seconds)
          || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > 300)
      {
          return Results.Unauthorized();
      }

      var signed = Encoding.UTF8.GetBytes($"{timestamp}.").Concat(raw).ToArray();
      var expected = Encoding.UTF8.GetBytes(
          Convert.ToHexString(HMACSHA256.HashData(secret, signed)).ToLowerInvariant());

      var valid = req.Headers["x-flowsign-signature"].ToString()
          .Split(',')
          .Select(entry => entry.Trim())
          .Where(entry => entry.StartsWith("v1="))
          .Any(entry => CryptographicOperations.FixedTimeEquals(
              Encoding.UTF8.GetBytes(entry[3..]),
              expected));

      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.

## Rotating the secret

Rotate a secret from the endpoint's actions in **Settings > Webhooks**, or with `POST /api/v1/webhooks/{endpointId}/rotate-secret`. The new secret is returned once. For the next **24 hours** every delivery is signed with both the new and the previous secret, so a receiver that still holds the old one keeps verifying while you switch over. After that only the new secret signs. Rotating again inside the 24 hours retires the older secret at once.

## Sending a test event

**Send test event** in the endpoint's actions, or `POST /api/v1/webhooks/{endpointId}/test` with `{ "event": "PACKAGE_COMPLETED" }`, sends a signed sample of any event the endpoint subscribes to and reports the outcome straight away. Asking for an event the endpoint is not subscribed to returns `422`. The body carries `"test": true` and sample `data` in the event's usual shape. A test is tried once, even while the endpoint is paused or failing, appears in the delivery log marked as a test, and never counts towards the endpoint failing.

## Retries

If your endpoint returns a non-`2xx` status (including a `3xx` redirect), times out, cannot be reached, or its host does not resolve, the delivery is retried with exponential backoff. The delays below are the minimum for each step: random jitter makes each real delay between one and two times the value shown.

| 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 and a quarter to eight and a half hours depending on the jitter, the delivery is marked **exhausted** and not retried. A delivery is also exhausted straight away, with no retries, if the endpoint's host is refused at delivery time because it resolves to a private or loopback address. The connection then goes to the address that was checked, never to a second lookup.

Each exhausted delivery increments the endpoint's failure counter; a successful delivery resets it to zero. Individual failed attempts that are still being retried do not count. At **10 exhausted deliveries in a row** the endpoint is shown as **Failing**. A failing endpoint still records every new event but sends nothing until you resume it. Resuming it in Settings, or `PATCH /api/v1/webhooks/{endpointId}` with `{ "enabled": true }`, clears the counter and sends the held deliveries, oldest first.

Pausing an endpoint stops deliveries without counting failures. Events keep being recorded while it is paused, and retries already scheduled keep their place; resuming sends all of them, oldest first.

## Delivery log

Every delivery is recorded with its event, status (**Pending**, **Retrying**, **Delivered** or **Exhausted**), attempt count, next attempt time, response status and any error; the response body is not exposed. Read it from the deliveries panel in **Settings > Webhooks**, which pages through 25 at a time and filters by status, or from `GET /api/v1/webhooks/{endpointId}` with `page`, `pageSize` and `status`. The API reports the statuses as `PENDING`, `FAILED` (shown as **Retrying** in the app), `DELIVERED` and `EXHAUSTED`. Deliveries are kept for **30 days**.

An exhausted delivery can be sent again with **Retry now** in the panel, or `POST /api/v1/webhooks/{endpointId}/deliveries/{deliveryId}/retry`. It keeps its delivery id and payload and starts a fresh round of attempts. Retrying a delivery in any other status returns `409`.
