# Webhooks

> Have Sigil POST a signed JSON event to your server when a pact is sent, viewed, signed, declined, voided, sealed or lapses. What is sent, how to verify it, and how retries work.

A webhook is a URL of yours that Sigil posts to each time one of your pacts changes. Instead of asking every few minutes whether anyone has signed, your server is told the moment it happens, with the pact as it stands and a signature that proves the message came from Sigil. Webhooks come with the Pro and Business plans, the ones that include the API.

Everything on this page is also in the [API reference](/api-reference/webhooks/list-webhooks), and every endpoint can be managed from the dashboard's [Webhooks page](https://www.signwithsigil.com/webhooks).

## Create an endpoint

On the dashboard, open [Webhooks](https://www.signwithsigil.com/webhooks), enter the URL, choose the events (or leave "All events" ticked) and add it. Or with the API:

```bash
curl https://api.signwithsigil.com/v1/webhooks \
  -H "Authorization: Bearer $SIGIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/sigil/webhook", "events": ["pact.sealed", "pact.declined"], "description": "Production CRM" }'
```

```json title="Answer"
{
  "uid": "W8NQ3KTE",
  "url": "https://example.com/sigil/webhook",
  "description": "Production CRM",
  "events": ["pact.sealed", "pact.declined"],
  "active": true,
  "consecutive_failures": 0,
  "last_status": null,
  "date_last_success": null,
  "date_disabled": null,
  "date_created": "2026-10-20T13:58:21Z",
  "date_updated": "2026-10-20T13:58:21Z",
  "secret": "sgw_Qm4Vx7Kp2Lr9TcNb0YhWzS8EaDj3GuPo6RiK1tHfXe"
}
```

The `secret` is shown **once**, here and on the dashboard right after creation. Store it where your receiver can read it. If you lose it, [roll it](#rolling-the-secret). An account can have ten endpoints; use one per system so each can be paused or removed on its own.

The URL must be a public `https://` address with a valid certificate. Sigil refuses `http://`, user names and passwords in the URL, and any host that is or resolves to a private, loopback or link-local address, both when you register it and again each time it connects.

## Events

| Type | When it fires | `data` carries |
|---|---|---|
| `pact.sent` | A pact was sent and its first signer invited. | `pact` (`status: "sent"`, `current_signer: 1`) |
| `pact.viewed` | A signer opened their signing link for the first time. Repeat opens within half an hour from the same address do not count. | `pact`, `recipient` (`status: "viewed"`) |
| `pact.signed` | A signer signed. The next signer is being invited, or, if this was the last, `pact.sealed` follows. | `pact` (still `sent` while others remain), `recipient` (`status: "signed"`, with `typed_name` and `date_signed`) |
| `pact.declined` | A signer declined. The pact is closed; nobody after them is invited. | `pact` (`status: "declined"`), `recipient` (with `decline_reason`) |
| `pact.voided` | The pact was withdrawn by its owner, from the dashboard, the API, MCP or the CLI. | `pact` (`status: "voided"`) |
| `pact.sealed` | Everyone signed. The stamped PDF and the sigil are ready at `data.pact.links.signed` and `.sigil`. | `pact` (`status: "sealed"`, `signed_sha256` set) |
| `pact.lapsed` | The deadline passed before everyone signed. | `pact` (`status: "lapsed"`) |
| `ping` | A test you asked for, from the dashboard or [Send a test](/api-reference/webhooks/ping-webhook). Every endpoint receives it, whatever it subscribed to. | `endpoint` (`uid`, `url`) |

Every pact of the account produces events, whichever way it was created or sent: the website, the API, the MCP server or the CLI. Drafts produce none until they are sent.

## The request

Each event is one `POST` to your URL with a JSON body and these headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json; charset=utf-8` |
| `User-Agent` | `Sigil-Webhooks/1.0 (+https://docs.signwithsigil.com/webhooks)` |
| `X-Sigil-Event` | The event type, such as `pact.sealed`. |
| `X-Sigil-Delivery` | This delivery's id. A retry repeats it, so it is what you deduplicate on. |
| `X-Sigil-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>`. See [Verifying the signature](#verifying-the-signature). |

The body is the [event object](/api-reference/objects/webhook-event):

```json title="pact.sealed"
{
  "id": "EV3QK9WT7PZMXDH4",
  "type": "pact.sealed",
  "created": "2026-10-21T09:12:45Z",
  "data": {
    "pact": {
      "uid": "K4TQ7MZE",
      "title": "Consulting agreement with Acme",
      "status": "sealed",
      "message": "Please sign by Friday.",
      "page_count": 3,
      "created_by": "api",
      "current_signer": null,
      "date_created": "2026-10-20T14:05:00Z",
      "date_sent": "2026-10-20T14:05:01Z",
      "date_lapses": "2026-11-03T14:05:01Z",
      "date_sealed": "2026-10-21T09:12:44Z",
      "date_updated": "2026-10-21T09:12:44Z",
      "documents_charged": 1,
      "documents_cost": null,
      "sent_sha256": "9f2c…",
      "signed_sha256": "4b1e…",
      "recipients": [
        { "order": 1, "role": "signer", "name": "Avery Lane", "email": "avery@example.com", "status": "signed", "date_viewed": "2026-10-20T15:28:02Z", "date_signed": "2026-10-20T15:31:40Z", "typed_name": "Avery Lane", "decline_reason": null },
        { "order": 2, "role": "signer", "name": "Sam Ortiz", "email": "sam@example.com", "status": "signed", "date_viewed": "2026-10-21T09:10:15Z", "date_signed": "2026-10-21T09:12:44Z", "typed_name": "Sam Ortiz", "decline_reason": null },
        { "order": 3, "role": "cc", "name": "Records", "email": "records@example.com", "status": "pending", "date_viewed": null, "date_signed": null, "typed_name": null, "decline_reason": null }
      ],
      "links": {
        "document": "https://api.signwithsigil.com/v1/pacts/K4TQ7MZE/document.pdf",
        "signed": "https://api.signwithsigil.com/v1/pacts/K4TQ7MZE/signed.pdf",
        "sigil": "https://api.signwithsigil.com/v1/pacts/K4TQ7MZE/sigil.pdf",
        "dashboard": "https://www.signwithsigil.com/pacts/K4TQ7MZE"
      }
    }
  }
}
```

```json title="pact.signed"
{
  "id": "EV7PZ2KQMX9TWH4D",
  "type": "pact.signed",
  "created": "2026-10-20T15:31:40Z",
  "data": {
    "pact": { "uid": "K4TQ7MZE", "status": "sent", "current_signer": 2, "…": "…" },
    "recipient": { "order": 1, "role": "signer", "name": "Avery Lane", "email": "avery@example.com", "status": "signed", "date_viewed": "2026-10-20T15:28:02Z", "date_signed": "2026-10-20T15:31:40Z", "typed_name": "Avery Lane", "decline_reason": null }
  }
}
```

```json title="ping"
{
  "id": "PNG4K7QWT9ZXMEH2",
  "type": "ping",
  "created": "2026-10-20T14:00:12Z",
  "data": { "endpoint": { "uid": "W8NQ3KTE", "url": "https://example.com/sigil/webhook" } }
}
```

`data.pact` is the [pact object](/api-reference/objects/pact) as it was the instant the event happened, without `fields` and `events`; the download links in `links` need your API key, as always. [Get the pact](/api-reference/pacts/get-pact) when you want the field values or the audit trail. `data.recipient` is the signer a `viewed`, `signed` or `declined` event is about.

## Verifying the signature

Every delivery is signed with the endpoint's secret, so your receiver can be sure it came from Sigil and was not altered. The header is

```text
X-Sigil-Signature: t=1761037965,v1=5d41402abc4b2a76b9719d911017c592…
```

where `t` is the time of the attempt in Unix seconds and `v1` is the lowercase hex HMAC-SHA256, keyed with the secret, of the string `t`, a full stop, and the **raw request body**. To verify:

1. Read the raw body bytes before anything parses them. A re-serialised object is not the same bytes.
2. Split the header on `,`; take the value after `t=` and after `v1=`.
3. Compute `HMAC-SHA256(secret, t + "." + body)` and compare it with `v1` using a constant-time comparison.
4. Refuse the request if `t` is more than five minutes from your clock. That stops a captured delivery from being replayed later.

Answer `401` when it fails, and nothing else: Sigil records the status and retries, which is what you want if you have just [rolled the secret](#rolling-the-secret).

```js title="Node (Express)"
import express from 'express';
import crypto from 'node:crypto';

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

app.post('/sigil/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Sigil-Signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
  const t = Number(parts.t);
  const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.`).update(req.body).digest('hex');
  const fresh = Number.isFinite(t) && Math.abs(Date.now() / 1000 - t) < 300;
  const ok = fresh && parts.v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
  if (!ok) return res.status(401).send('bad signature');

  const event = JSON.parse(req.body);
  // Answer first, work after: anything slow belongs in a queue, keyed by req.get('X-Sigil-Delivery').
  res.sendStatus(204);
  if (event.type === 'pact.sealed') console.log('sealed:', event.data.pact.uid, event.data.pact.links.signed);
});

app.listen(3000);
```

```python title="Python (Flask)"
import hmac, hashlib, os, time
from flask import Flask, request, abort

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

@app.post("/sigil/webhook")
def sigil_webhook():
    header = request.headers.get("X-Sigil-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    body = request.get_data()  # raw bytes, before any parsing
    expected = hmac.new(SECRET, parts.get("t", "").encode() + b"." + body, hashlib.sha256).hexdigest()
    fresh = parts.get("t", "").isdigit() and abs(time.time() - int(parts["t"])) < 300
    if not (fresh and hmac.compare_digest(expected, parts.get("v1", ""))):
        abort(401)

    event = request.get_json(force=True)
    if event["type"] == "pact.sealed":
        print("sealed:", event["data"]["pact"]["uid"])
    return "", 204
```

```csharp title="C# (ASP.NET Core)"
using System.Security.Cryptography;
using System.Text;

app.MapPost("/sigil/webhook", async (HttpRequest request) =>
{
    string secret = Environment.GetEnvironmentVariable("SIGIL_WEBHOOK_SECRET")!;
    using MemoryStream buffer = new();
    await request.Body.CopyToAsync(buffer);
    byte[] body = buffer.ToArray();

    var parts = (request.Headers["X-Sigil-Signature"].ToString()).Split(',')
        .Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToDictionary(p => p[0], p => p[1]);
    if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1) || !long.TryParse(t, out long ts))
        return Results.Unauthorized();
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
        return Results.Unauthorized();

    byte[] signed = [.. Encoding.UTF8.GetBytes(t + "."), .. body];
    string expected = Convert.ToHexStringLower(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed));
    if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(v1)))
        return Results.Unauthorized();

    var evt = System.Text.Json.JsonDocument.Parse(body).RootElement;
    // Queue the work by request.Headers["X-Sigil-Delivery"]; answer right away.
    return Results.NoContent();
});
```

```php title="PHP"
<?php
$secret = getenv('SIGIL_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$parts = [];
foreach (explode(',', $_SERVER['HTTP_X_SIGIL_SIGNATURE'] ?? '') as $p) {
    [$k, $v] = array_pad(explode('=', $p, 2), 2, '');
    $parts[$k] = $v;
}
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $body, $secret);
$fresh = ctype_digit($parts['t'] ?? '') && abs(time() - (int) $parts['t']) < 300;
if (!$fresh || !hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);
http_response_code(204);
// handle $event['type'] after responding, or quickly
```

## Responding

Answer any `2xx` within **10 seconds**, then do your work. If the work takes time, put the event on a queue and answer first; Sigil does not read your response body except to show the start of it in the delivery log when the status is not `2xx`.

- A status other than `2xx`, a timeout or a connection failure is retried (below).
- Redirects (`3xx`) are not followed and count as failures. Give Sigil the final URL.
- `410 Gone` means "stop sending": the endpoint is disabled at once and you are emailed.

## Delivery, retries and order

Delivery is **at least once**. A delivery that times out after your server has in fact handled it is retried, so the same `X-Sigil-Delivery` can arrive twice: keep the ids you have processed (a day is plenty) and ignore repeats. Events for one pact are published in the order they happened, but a retried event arrives after later ones that got through, so act on what the body says (`data.pact.status`) rather than on the order of arrival, or [fetch the pact](/api-reference/pacts/get-pact) when it matters.

The first attempt is made immediately. A failed attempt is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (each a little randomised), seven attempts in about a day and a half, after which the delivery is marked `failed` and stays in the log for 30 days. You can [redeliver](/api-reference/webhooks/redeliver) any delivery, failed or not, from the dashboard or the API.

After **10 deliveries in a row** have failed for good, the endpoint is disabled: `active` becomes `false`, nothing more is queued for it, and the account's email address gets one message saying so. Events that happen while an endpoint is off are not queued for it. Once the receiver is fixed, enable the endpoint again on the dashboard or with `PATCH /webhooks/{uid}` and `{ "active": true }`, and redeliver what you missed, or fetch the pacts.

## Rolling the secret

[Roll the secret](/api-reference/webhooks/roll-secret) (or the dashboard's "Roll secret" button) replaces it at once; there is no overlap, so update your receiver right away. If a delivery in flight fails verification because of the switch, answer `401` and it is retried, signed with the new secret. A receiver that must never miss a beat can accept the previous secret as well for a minute.

## Testing

- **Send a test** from the endpoint's page on the dashboard, or `POST /webhooks/{uid}/ping`. It posts a `ping` event right away and shows how your server answered.
- **The delivery log** on the same page, or [List deliveries](/api-reference/webhooks/list-deliveries), shows every event queued for the endpoint, its attempts, the last status and error, and the exact body that was posted ([Get a delivery](/api-reference/webhooks/get-delivery)).
- **Locally**, point an endpoint at a tunnel to your machine (ngrok, Cloudflare Tunnel and the like give you a public HTTPS URL), or at a request-inspection service while you look at the shape of the data.
- **End to end:** create an endpoint, [send a pact](/api-reference/pacts/create-pact) with `"send": true`, and watch `pact.sent` arrive; open the signing link yourself for `pact.viewed`; sign for `pact.signed` and, with one signer, `pact.sealed`.

## Troubleshooting

| `last_error` says | What it means |
|---|---|
| `timed out after 10s` | Your server did not answer in time. Answer first, work later. |
| `HTTP 401` / `HTTP 403` | Your server refused the request. Check the signature code and that the secret is the current one. |
| `HTTP 3xx: redirects are not followed` | The URL redirects (often `http` to `https`, or a missing trailing slash). Register the final URL. |
| `HTTP 404` | Nothing is listening at that path. |
| `HTTP 5xx: …` | Your server failed; the start of its response is shown. |
| `… resolves to no public address` | The host name points at a private, loopback or link-local address. Webhooks are only delivered to public addresses. |
| A TLS or certificate error | The certificate is expired, self-signed or for another name. Sigil verifies certificates like a browser does. |
| `endpoint disabled` | The endpoint was off when the delivery was attempted; nothing was posted. |

If the endpoint shows as disabled with a failure count, fix the cause, send a test, then enable it again.
