Docs

Get started

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, and every endpoint can be managed from the dashboard's Webhooks page.

Create an endpoint

On the dashboard, open Webhooks, enter the URL, choose the events (or leave "All events" ticked) and add it. Or with the API:

Terminal
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" }'
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. 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. 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.

The body is the event object:

{
  "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"
      }
    }
  }
}

data.pact is the pact object 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 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.

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);

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 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 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 (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, shows every event queued for the endpoint, its attempts, the last status and error, and the exact body that was posted (Get a 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 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.