---
title: "Webhooks"
description: "Signer webhooks: Signer sends a POST to a URL of your application when a document is created, signed, approved, refused, concluded, canceled, paused, resumed, expired or deleted."
canonical: "https://docs.lacunasoftware.com/en-us/articles/signer/webhooks"
locale: "en"
product: "Signer"
last_updated: "2026-10-22"
---

# Webhooks

A **webhook** is a URL of your application that Signer calls when something happens to a document of your
organization. For example, Signer sends a `POST` to that URL when a participant signs a document or when
the flow finishes.

Use webhooks to track documents. Without them, your application has to call `GET /api/documents/{id}`
over and over until the state changes, which is called *polling*. With webhooks, Signer tells your
application right after the event.

## How it works

The diagram shows the path of one event, from the participant's action to your application:

```text
  Participant          Signer                           Your application
      |                   |                                   |
      |-- signs --------->|                                   |
      |                   |-- POST https://your-url --------->|  1. Checks the authentication header
      |                   |   { "type": ..., "data": ... }    |
      |                   |<-- 200 OK ------------------------|  2. Answers at once
      |                   |                                   |  3. Processes the event in the background
      |                   |                                   |
```

If your application does not answer `2xx`, Signer tries to send the event again later. The
[Delivery, retries and ordering](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#delivery-retries-and-ordering) section explains the delivery rules.

To integrate webhooks, follow these steps:

1. [Register the webhook URL](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#registering-a-webhook) in your organization.
2. [Choose how Signer authenticates the call](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#authenticating-the-call).
3. [Create the endpoint that receives the events](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#receiving-webhooks-in-code).
4. Use the [Event reference](https://docs.lacunasoftware.com/en-us/articles/signer/webhook-events) page to read the data of each event type.

## Register a webhook

1. In the side menu, click **Organizations** and select your organization.
2. In the **Integration** section, turn on **Enable webhooks**.
3. In the **URL** field, enter the URL that will receive the events. Use HTTPS.
4. In **Authentication type**, choose how Signer authenticates the call. See
   [Authenticate the call](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#authenticating-the-call).
5. If you chose a type other than **No authentication**, enter the secret in **Authentication Token**.
6. Click **Save**.

![Integration section of the Organizations screen, with the Enable webhooks option and the URL field](https://docs.lacunasoftware.com/images/signer/org-webhook.png)

> **Note: Limits**
>
> * An organization can have several webhooks. Every webhook receives every event.
> * The instance's `MaxWebhooksPerOrganization` setting defines how many webhooks an organization can have.
>   The default is **5**. If the organization is already at the limit, Signer does not accept a new webhook.
> * Personal organizations have no webhooks.

## Authenticate the call

Signer does not sign the request body. To show that the call comes from Signer, it sends a header with the
token you registered. The **Authentication type** sets which header Signer sends:

| Authentication type | Header sent |
|---|---|
| No authentication | None |
| Basic | `Authorization: Basic <token>` |
| Bearer | `Authorization: Bearer <token>` |
| X-Api-Key | `X-Api-Key: <token>` |

> **Warning**
>
> Use HTTPS and an authentication type other than **No authentication**. Check the header on every request
> before you process the event. Without that check, anyone who knows your URL can send fake events.

> **Note: Basic mode**
>
> Signer sends the token exactly as you registered it and encodes nothing. In **Basic** mode, register the
> value already Base64-encoded, in the `user:password` format.

## Receive webhooks in code

The samples below show a minimal endpoint in each language. Each endpoint does four things, in the same
order as the numbers in the code comments:

1. Checks the authentication header.
2. Answers `200` at once.
3. Ignores repeated events.
4. Reads the document back before acting on it.

The samples are trimmed from the `WebhookEndpointScenario` of the SignerSamples repository. They use no web
framework. C# uses `HttpListener`, Java uses the JDK's `HttpServer`, Node.js uses the `http` module and PHP
uses its built-in server. The full scenarios also show how to test the endpoint with curl. 

**C#**

```csharp
using Lacuna.Signer.Api;
using Newtonsoft.Json.Linq;

// "Bearer <token>": the token registered with the webhook, with authType Bearer
private static readonly string ExpectedAuthorization = "Bearer " + Environment.GetEnvironmentVariable("SIGNER_WEBHOOK_TOKEN");
// Keys of the events already received; production needs durable storage, such as a table with a unique key
private readonly ConcurrentDictionary<string, bool> receivedEvents = new ConcurrentDictionary<string, bool>();

// Called for each request of an HttpListener started on http://localhost:8080/
private async Task HandleRequestAsync(HttpListenerContext context)
{
    var request = context.Request;
    var response = context.Response;

    // 1. Check the auth header: Signer does not sign the body
    if (!IsAuthorized(request.Headers["Authorization"]))
    {
        response.StatusCode = 401;
        response.Close();
        return;
    }

    // The body is { "type": "...", "data": { ... } }. Read it as JSON rather than as a WebhookModel,
    // which rejects the event types that its WebhookTypes enum doesn't list.
    string body;
    using (var reader = new StreamReader(request.InputStream, Encoding.UTF8))
    {
        body = await reader.ReadToEndAsync();
    }
    var webhook = JObject.Parse(body);

    // 3. Be idempotent. GetEventKey (in the sample) joins the type, the document id and the flowActionId.
    var isNew = receivedEvents.TryAdd(GetEventKey(webhook), true);

    // 2. Answer 200 at once (also to a repeated event, so that Signer stops sending it), then process
    response.StatusCode = 200;
    response.Close();
    if (!isNew)
    {
        return;
    }

    // 4. Don't trust the order of events: read the document before acting on it
    if ((string)webhook["type"] == nameof(WebhookTypes.DocumentConcluded))
    {
        var document = await signerClient.GetDocumentDetailsAsync((Guid)webhook["data"]["id"]);
        if (document.Status == DocumentStatus.Concluded)
        {
            // Download the signed file, update your records...
        }
    }
}

private static bool IsAuthorized(string authorization)
{
    // Compare SHA-256 hashes, so that the comparison time doesn't reveal how much of the token matched
    using (var sha256 = SHA256.Create())
    {
        var expected = sha256.ComputeHash(Encoding.UTF8.GetBytes(ExpectedAuthorization));
        var actual = sha256.ComputeHash(Encoding.UTF8.GetBytes(authorization ?? ""));
        return actual.SequenceEqual(expected);
    }
}
```

**Java**

```java
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.lacunasoftware.signer.DocumentStatus;
import com.lacunasoftware.signer.WebhookTypes;
import com.lacunasoftware.signer.documents.DocumentModel;
import com.lacunasoftware.signer.webhooks.WebhookModel;
import com.sun.net.httpserver.HttpExchange;

// "Bearer <token>": the token registered with the webhook, with authType Bearer
private static final String EXPECTED_AUTHORIZATION = "Bearer " + System.getenv("SIGNER_WEBHOOK_TOKEN");
// Keys of the events already received; production needs durable storage, such as a table with a unique key
private final Set<String> receivedEvents = ConcurrentHashMap.newKeySet();
// Processes the events after they are answered, off the server's thread
private final ExecutorService processor = Executors.newSingleThreadExecutor();

// The handler of HttpServer.create(new InetSocketAddress("localhost", 8080), 0).createContext("/webhook", ...)
private void handleRequest(HttpExchange exchange) throws IOException {
    // 1. Check the auth header: Signer does not sign the body. MessageDigest.isEqual compares in constant time.
    String authorization = exchange.getRequestHeaders().getFirst("Authorization");
    if (authorization == null || !MessageDigest.isEqual(
            authorization.getBytes(StandardCharsets.UTF_8),
            EXPECTED_AUTHORIZATION.getBytes(StandardCharsets.UTF_8))) {
        exchange.sendResponseHeaders(401, -1);
        exchange.close();
        return;
    }

    // The body is { "type": "...", "data": { ... } }. signerClient.getGson() reads the API's enums and dates.
    Gson gson = signerClient.getGson();
    JsonElement body;
    try (InputStreamReader reader = new InputStreamReader(exchange.getRequestBody(), StandardCharsets.UTF_8)) {
        body = gson.fromJson(reader, JsonElement.class);
    }

    // 3. Be idempotent. getEventKey (in the sample) joins the type, the document id and the flowActionId.
    boolean isNew = receivedEvents.add(getEventKey(gson.fromJson(body, Event.class)));

    // 2. Answer 200 at once (also to a repeated event, so that Signer stops sending it), then process
    exchange.sendResponseHeaders(200, -1);
    exchange.close();
    if (isNew) {
        processor.execute(() -> processEvent(gson, body));
    }
}

private void processEvent(Gson gson, JsonElement body) {
    try {
        WebhookModel webhook = gson.fromJson(body, WebhookModel.class);
        if (webhook.getType() == WebhookTypes.DOCUMENTCONCLUDED) {
            // 4. Don't trust the order of events: read the document before acting on it
            String documentId = body.getAsJsonObject().getAsJsonObject("data").get("id").getAsString();
            DocumentModel document = signerClient.getDocumentDetails(UUID.fromString(documentId));
            if (document.getStatus() == DocumentStatus.CONCLUDED) {
                // Download the signed file, update your records...
            }
        }
    } catch (Exception e) {
        // Already answered, so Signer won't send it again: keep the event and retry it from your storage
    }
}
```

**Node.js**

```ts
import * as crypto from "crypto";
import * as http from "http";
import { DocumentStatus, WebhooksWebhookModel, WebhookTypes } from "signer-node-client";

// "Bearer <token>": the token registered with the webhook, with authType Bearer
const expectedAuthorization = "Bearer " + process.env.SIGNER_WEBHOOK_TOKEN;
// Keys of the events already received; production needs durable storage, such as a table with a unique key
const receivedEvents = new Set<string>();

http.createServer((req, res) => {
  // 1. Check the auth header: Signer does not sign the body
  if (!isAuthorized(req.headers.authorization)) {
    res.writeHead(401).end();
    return;
  }

  const chunks: Buffer[] = [];
  req.on("data", (chunk: Buffer) => chunks.push(chunk));
  req.on("end", () => {
    // The body is { "type": "...", "data": { ... } }
    let webhook: WebhooksWebhookModel;
    let key: string;
    try {
      webhook = JSON.parse(Buffer.concat(chunks).toString("utf8"));
      // 3. Be idempotent. getEventKey (in the sample) joins the type, the document id and the flowActionId.
      key = getEventKey(webhook);
    } catch {
      res.writeHead(400).end();
      return;
    }
    const isNew = !receivedEvents.has(key);
    receivedEvents.add(key);

    // 2. Answer 200 at once (also to a repeated event, so that Signer stops sending it), then process
    res.writeHead(200).end();
    if (isNew) {
      processEvent(webhook).catch((err) => console.error(`Could not process ${key}: ${err.message}`));
    }
  });
}).listen(8080, "localhost");

function isAuthorized(authorization: string | undefined): boolean {
  // timingSafeEqual needs inputs of the same length, so compare SHA-256 hashes
  const hash = (value: string) => crypto.createHash("sha256").update(value).digest();
  return crypto.timingSafeEqual(hash(authorization ?? ""), hash(expectedAuthorization));
}

async function processEvent(webhook: WebhooksWebhookModel) {
  if (webhook.type === WebhookTypes.DocumentConcluded) {
    // 4. Don't trust the order of events: read the document before acting on it
    const document = (await documentsApi.apiDocumentsIdGet(webhook.data.id)).data;
    if (document.status === DocumentStatus.Concluded) {
      // Download the signed file, update your records...
    }
  }
}
```

**PHP**

```php
// webhook.php, served with: php -S localhost:8080 webhook.php
use Lacuna\Signer\Model\DocumentsDocumentModel;
use Lacuna\Signer\Model\DocumentStatus;
use Lacuna\Signer\Model\WebhookTypes;

// 1. Check the auth header: Signer does not sign the body. hash_equals compares in constant time.
//    "Bearer <token>": the token registered with the webhook, with authType Bearer
if (!hash_equals('Bearer ' . getenv('SIGNER_WEBHOOK_TOKEN'), $_SERVER['HTTP_AUTHORIZATION'] ?? '')) {
    http_response_code(401);
    return;
}

// The body is { "type": "...", "data": { ... } }
$webhook = json_decode(file_get_contents('php://input'), true);
if (!is_array($webhook) || !is_string($webhook['type'] ?? null) || $webhook['type'] === '') {
    http_response_code(400);
    return;
}

// 3. Be idempotent. getEventKey (in the sample) joins the type, the document id and the flowActionId;
//    recordEvent returns false for a key already recorded (the sample creates one file per key;
//    production needs durable storage, such as a table with a unique key).
if (!recordEvent(getEventKey($webhook))) {
    http_response_code(200);
    return;
}

// 2. Answer 200 at once, then process the event
ignore_user_abort(true);
http_response_code(200);
header('Content-Length: 0');
header('Connection: close');
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request(); // PHP-FPM
} else {
    // Other servers, such as PHP's built-in one: send the headers now
    while (ob_get_level() > 0) {
        ob_end_flush();
    }
    flush();
}

// 4. Don't trust the order of events: read the document before acting on it
if ($webhook['type'] === WebhookTypes::DOCUMENT_CONCLUDED) {
    $document = new DocumentsDocumentModel($signerClient->getDocumentDetails($webhook['data']['id']));
    if ($document->getStatus() === DocumentStatus::CONCLUDED) {
        // Download the signed file, update your records...
    }
}
```

See the full endpoints: [C#](https://github.com/LacunaSoftware/SignerSamples/blob/master/dotnet/console/Console/Scenarios/WebhookEndpointScenario.cs) · [Java](https://github.com/LacunaSoftware/SignerSamples/blob/master/java/console/src/main/java/com/lacunasoftware/signer/sample/scenarios/WebhookEndpointScenario.java) · [PHP](https://github.com/LacunaSoftware/SignerSamples/blob/master/php/Scenarios/WebhookEndpointScenario.php) · [Node.js](https://github.com/LacunaSoftware/SignerSamples/blob/master/nodejs/scenarios/webhookEndpointScenario.ts). To create the client the samples use, see [Create documents](https://docs.lacunasoftware.com/en-us/articles/signer/integration/documents#step-by-step).

> **Note: Event types in the client libraries**
>
> The `WebhookTypes` enum of the .NET client and of the PHP client has no `DocumentDeleted`. This applies to
> `Lacuna.Signer.Api` 1.67.0, which `Lacuna.Signer.Client` 1.24.0 installs, and to the PHP client 1.4.10. The
> Java and Node.js enums have `DocumentDeleted`.
>
> If the enum of your library does not list an event type, compare the `type` of the body as text. The C# and
> PHP samples above do that.

## Events

The body of every request has the `{ "type": ..., "data": ... }` format. The `type` field tells the event
type, such as `DocumentSigned` or `DocumentConcluded`. The content of `data` depends on the type.

For the list of types, the fields of each event and body samples, see the page
[Event reference](https://docs.lacunasoftware.com/en-us/articles/signer/webhook-events).

## Delivery, retries and ordering

This section explains how Signer sends events. Most webhook integration problems come from these rules.

### Asynchronous delivery

Signer does not send the event at the same instant as the user's action. A background process sends each
event, from a low priority queue. A small delay is normal.

If the organization has more than one webhook, Signer sends the event to each webhook separately. A failure
in one webhook does not affect the others.

### Retries

Your application must answer with a `2xx` code. If it answers with another code, or if the connection
fails, Signer marks the delivery as failed. Signer then tries to send the event again, up to 10 times. The
wait grows with each attempt:

| Retry | Wait after the previous failure |
|---|---|
| 1st | 15 to 45 seconds |
| 2nd | 15 seconds to 1 minute 15 seconds |
| 3rd | 30 seconds to 2 minutes |
| 4th | 1 minute 35 seconds to 3 minutes 30 seconds |
| 5th | 4 minutes 30 seconds to 7 minutes |
| 6th | 10 minutes 40 seconds to 13 minutes 30 seconds |
| 7th | 22 to 25 minutes |
| 8th | 40 to 44 minutes |
| 9th | 1 hour 8 minutes to 1 hour 13 minutes |
| 10th | 1 hour 50 minutes to 1 hour 55 minutes |

The 10 retries take, in total, from 4 hours 20 minutes to 4 hours 45 minutes after the first failure. Each
wait has a random part, so the table shows ranges. After the 10th failure, Signer stops sending that event. 

On each retry, Signer builds the event again from the current document data. The state check described in
[Event order](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#event-order) also applies to each retry.

Signer logs the status code and the response body of each failure. If you investigate a delivery problem,
ask the instance administrator for those records.

### Repeated deliveries

Because of the retries, the same event can arrive more than once. Make your processing **idempotent**, that
is, safe to run more than once for the same event. To recognize an event you already processed, use the
`type`, the document `id` and, when there is one, the `flowActionId`.

### Event order

Signer does not guarantee the order of events, because independent processes deliver them. For example,
the `DocumentConcluded` of a document can arrive before the `DocumentSigned` of the same document.

Before it sends some events, Signer checks that the document is still in the event's state. If the state
changed, Signer drops the event. For example, if someone pauses a document and resumes it right after, your
application may get only `DocumentResumed`, without `DocumentPaused`. This check applies to
`DocumentConcluded`, `DocumentCanceled`, `DocumentPaused`, `DocumentResumed` and `DocumentExpired`.

## Best practices

* Answer `200` at once and process the event later, in the background. If the endpoint is slow, the call
  times out and Signer retries for no reason.
* Check the authentication header on every request.
* Keep the URL stable and reachable. Signer must reach the URL over the internet. In *on-premises*
  installations, Signer must reach it over the network where its server runs.
* Log the events you receive. The log helps you find differences between your system and Signer.

## Troubleshooting

If you need more detail about a delivery failure, ask the instance administrator for the Signer logs. For
each failure, the log shows the status code, the response body and the URL called.

If you fix the URL or the token of a webhook, the next retries of a failed event already use the new value.
Signer reads the webhook settings on every attempt.

### Your application gets no events

| Possible cause | How to fix |
|---|---|
| The **Enable webhooks** option is off. | In **Organizations**, **Integration** section, turn on **Enable webhooks** and click **Save**. |
| The webhook was registered after the action on the document. | Signer sends the event only to the webhooks that exist at the time of the action. It does not send events for actions before the webhook was registered. |
| The document is in a personal organization. | Personal organizations have no webhooks. Create the document in an organization that has the webhook registered. |
| Signer cannot connect to the URL. For example, the host name does not exist, a firewall blocks the port or the URL only works inside your network. | Check that the URL answers from a machine outside your network, for example with `curl -X POST <url>`. In *on-premises* installations, run the test from the Signer server. |
| The HTTPS certificate of the URL is not valid. For example, it is self-signed, it expired or it lacks the full chain. | Signer refuses the connection when the certificate is not valid. Use a certificate issued by a trusted certificate authority and configure the full chain on the server. |

### Your application answers `401` or `403`

| Possible cause | How to fix |
|---|---|
| The webhook's **Authentication type** is not what your endpoint expects. For example, the webhook uses **X-Api-Key** and the endpoint reads the `Authorization` header. | Choose in the webhook the type your endpoint reads. See the headers of each type in [Authenticate the call](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#authenticating-the-call). |
| The token registered in the webhook differs from the token the endpoint compares. | Register the token again in the webhook and in the endpoint. Check that there is no space or line break at the start or end of the token. |
| In **Basic** mode, the token is not Base64-encoded. | Signer sends the token without encoding it. Register the `user:password` value already Base64-encoded. |
| A proxy or gateway between Signer and your application removes or replaces the authentication header. | Check the proxy or gateway settings. It must pass the `Authorization` or `X-Api-Key` header to your application. |

### The same event arrives several times

| Possible cause | How to fix |
|---|---|
| Your application answered with a code other than `2xx`. | Answer `200` also for an event you already processed. If you answer with an error, Signer tries again. |
| Your application took more than 100 seconds to answer. | Signer cancels the call after 100 seconds and tries again. Answer `200` as soon as you get the event and process it later, in the background. |
| Retries are normal. | Make your processing idempotent. See [Repeated deliveries](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#repeated-deliveries). |

### Your application gets a `GET` or an empty body

| Possible cause | How to fix |
|---|---|
| The registered URL answers with a redirect (`301` or `302`). When it follows that redirect, Signer turns the `POST` into a `GET`, with no body. | Register the final URL, which answers without redirecting. Check the scheme (`https`), the domain (with or without `www`) and the trailing slash of the path. |
| The URL redirects from `https` to `http`. | Signer does not follow that redirect, and the delivery fails. Register the final `https` URL. |

### An expected event did not arrive

| Possible cause | How to fix |
|---|---|
| The document state changed before delivery. For example, someone paused a document and resumed it right after. | Signer drops the event when the document is no longer in the event's state. See [Event order](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#event-order). |
| Someone deleted the webhook before delivery. | Signer does not send the event to a deleted webhook. |
| All 10 retries failed. | Signer stops sending that event. Fix the cause of the failure so that the next events arrive. See [Retries](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#retries). |

### Other problems

| Problem | Cause and fix |
|---|---|
| Signer does not accept a new webhook. | The organization reached its webhook limit, set by the `MaxWebhooksPerOrganization` setting (5 by default). Delete a webhook, or ask the instance administrator to change the limit. |
| The `documents` list of a `DocumentDeleted` event does not have every deleted document. | In organization or folder deletions, the list has at most 100 documents. See [Event reference](https://docs.lacunasoftware.com/en-us/articles/signer/webhook-events#documentdeleted). |

## See also

* [Event reference](https://docs.lacunasoftware.com/en-us/articles/signer/webhook-events)
* [Getting Started](https://docs.lacunasoftware.com/en-us/articles/signer/get-started)
* [Integration Guide](https://docs.lacunasoftware.com/en-us/articles/signer/integration-guide)
* [Checking a document status](https://docs.lacunasoftware.com/en-us/articles/signer/integration/tracking#check-document)

**Demos and samples**

- Handle document webhooks (signed, approved, refused, concluded): [.NET](https://github.com/LacunaSoftware/SignerSamples/blob/master/dotnet/console/Console/Scenarios/CheckDocumentStatusScenario.cs) · [Java](https://github.com/LacunaSoftware/SignerSamples/blob/master/java/console/src/main/java/com/lacunasoftware/signer/sample/scenarios/CheckDocumentStatusScenario.java) · [Node.js](https://github.com/LacunaSoftware/SignerSamples/blob/master/nodejs/scenarios/checkDocumentStatusScenario.ts) · [PHP](https://github.com/LacunaSoftware/SignerSamples/blob/master/php/Scenarios/CheckDocumentStatusScenario.php)
- Handle the invoice-closed billing webhook: [.NET](https://github.com/LacunaSoftware/SignerSamples/blob/master/dotnet/console/Console/Scenarios/InvoiceWebhookHandlingScenario.cs) · [Java](https://github.com/LacunaSoftware/SignerSamples/blob/master/java/console/src/main/java/com/lacunasoftware/signer/sample/scenarios/InvoiceWebhookHandlingScenario.java) · [PHP](https://github.com/LacunaSoftware/SignerSamples/blob/master/php/Scenarios/InvoiceWebhookHandlingScenario.php)
- Mark an invoice as paid: [.NET](https://github.com/LacunaSoftware/SignerSamples/blob/master/dotnet/console/Console/Scenarios/InvoiceWebhookHandlingScenario.cs) · [Java](https://github.com/LacunaSoftware/SignerSamples/blob/master/java/console/src/main/java/com/lacunasoftware/signer/sample/scenarios/InvoiceWebhookHandlingScenario.java) · [PHP](https://github.com/LacunaSoftware/SignerSamples/blob/master/php/Scenarios/InvoiceWebhookHandlingScenario.php)
