# Guide to SignNow Webhooks: Features and Best Practices

SignNow webhooks tell your systems where a document is in its signing process: an invite is sent, a document is opened, a signer signs or declines, all fields are complete, or a document group finishes. If you send documents for signature with SignNow, the airSlate e-signature platform, webhooks are how that progress reaches your CRM, document storage and workflow engine without polling.

This guide covers how SignNow event subscriptions are scoped and created, how to make sure they are signed, the events and payloads they send, the retry and unsubscribe rules, and the practices that keep a SignNow receiver reliable.

## What are SignNow webhooks?

In SignNow, webhooks and event subscriptions are the same thing. A subscription links one event, such as `document.complete`, to one entity and one callback URL. The entity is a user, a document or a document group, identified by its ID, and it limits which activity the subscription hears about. A `user` subscription covers what that user does, a `document` subscription covers everything that happens to that document, and a `document_group` subscription covers everything that happens to that group.

Each subscription is a separate `POST` to the API, so integrations that need several events create several subscriptions. Webhooks are available with an API free trial, an active API subscription or a Site License.

SignNow only signs callbacks when the subscription was created with a `secret_key`. You choose the key yourself. When it is set, every callback carries an `X-SignNow-Signature` header holding the base64 HMAC-SHA256 of the request body. When it isn't, there is nothing to verify.

## SignNow webhook features

| Feature | Details |
| --- | --- |
| Configuration | `POST https://api.signnow.com/v2/event-subscriptions`, or **API Dashboard > Webhooks** |
| Scope | One event, one entity (`user`, `document` or `document_group`) and one callback URL per subscription |
| Limits | 20 active subscriptions per entity and event. 10 in total without an API subscription |
| Event identity | `meta.event` in the body |
| Signature header | `X-SignNow-Signature`, only with a `secret_key` |
| Scheme | HMAC-SHA256, base64 |
| Signed content | The raw request body |
| Secret | Your own string, up to 300 characters, set per subscription |
| Replay window | None documented. `meta.timestamp` is in the signed body |
| Response window | 20 seconds per attempt |
| Retries | Depend on the error: see below |
| Options | `retry_count`, `delay` (up to 100 seconds), `include_metadata`, custom `headers` |
| Logs | **API Dashboard > Webhooks > Events History**, or the callbacks API |

The callback URL must be publicly reachable. SignNow rejects `localhost` and private IP addresses, and checks that the host can be reached when you create the subscription. It publishes no source IP list.

## Common events

SignNow documents 96 events in three families. The `document` and `document_group` families describe what happens to one document or group, and the `user` family mirrors them for everything a user owns. Here are the ones most integrations need:

| Event | Fires when |
| --- | --- |
| `document.fieldinvite.sent` | A field invite for the document is sent |
| `document.open` | The document is opened |
| `document.fieldinvite.signed` | A field invite for the document is signed |
| `document.fieldinvite.decline` | A field invite for the document is declined |
| `document.complete` | All the required fields on the document are filled in |
| `document_group.invite.signer.complete` | A signer completes all documents in a group invite |
| `document_group.complete` | All required fields in a document group are filled in, or the invite has expired |
| `user.document.fieldinvite.signed` | A field invite sent by the user is signed |
| `user.document.complete` | All the required fields on one of the user's documents are filled in |

The full list also covers updates and deletes, templates, freeform invites, reassignments, consent, failed authentication, failed invite emails and expired invites. Spelling is not consistent between families: a declined field invite is `document.fieldinvite.decline`, but a declined group invite is `document_group.invite.declined`. Copy event names from SignNow's tables rather than guessing them.

Every callback has two parts, `meta` and `content`. Here is SignNow's `user.document.open` example:

```json
{
  "meta": {
    "timestamp": 1728310375,
    "event": "user.document.open",
    "environment": "https://app.signnow.com/api",
    "callback_url": "https://webhook.site/ba2650c4-f8a3-4664-a42f-a1ea40b17bbc?Param={\"docId\":\"3cd9758f093545089954894be9da90af210741b0\"}",
    "access_token": "********",
    "initiator_id": "646a5e78f4154a0880a596b11c21a95866ee66e2",
    "metadata": {
      "sales_rep": "Jane Doe",
      "source": "webhook"
    }
  },
  "content": {
    "document_id": "3cd9758f093545089954894be9da90af210741b0",
    "document_name": "Sales Agreement",
    "user_id": "646a5e78f4154a0880a596b11c21a95866ee66e2"
  }
}

```

`meta.event` names the event and `meta.timestamp` is in Unix seconds. `content` depends on the entity: document events carry `document_id` and `document_name`, while group invite events carry `group_id`, `group_invite_id`, `status`, the signer's details and `document_ids`. `meta.metadata` only appears when the subscription sets `include_metadata`, and it is an object when the document has metadata but an empty array (`[]`) when it doesn't, so handle both.

The documented examples show `access_token` masked. SignNow doesn't say whether live callbacks mask it, so treat callback bodies as sensitive and keep them out of general-purpose logs.

## Setting up SignNow webhooks

1. Choose a `secret_key`: any string up to 300 characters, such as the output of `openssl rand -hex 32`. Store it server-side as `SIGNNOW_WEBHOOK_SECRET`.
2. Get an access token and the ID of the user, document or document group you want to watch.
3. Create one subscription per event, with the secret in `attributes.secret_key`:

```bash
curl -X POST "https://api.signnow.com/v2/event-subscriptions" \
  -H "Authorization: Bearer $SIGNNOW_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "document.complete",
    "entity_id": "0f7848d0cfadeb2cdd230e4ad433efbca2423d8f",
    "attributes": {
      "callback": "https://example.com/webhooks/signnow",
      "secret_key": "'"$SIGNNOW_WEBHOOK_SECRET"'",
      "include_metadata": true
    }
  }'

```

In the API Dashboard, go to **Webhooks** and click **Create Webhook** to create several subscriptions at once. Enter your own key, or click **Generate New Key** and copy it before you save. `GET /v2/event-subscriptions` returns the configured `secret_key` if you need to check it later.

If you use Hookdeck, create a SignNow source, set the same key as its **Webhook Signing Secret**, and use the source URL as the `callback`. Set a `secret_key` on every subscription that points at the source. Without one, SignNow sends unsigned callbacks and they fail verification. If you decide not to sign at all, leave verification off on the source. Because each subscription has its own key, give every subscription that shares a Hookdeck source the same `secret_key`.

## Securing SignNow webhooks

Compute an HMAC-SHA256 of the raw request body with the `secret_key` as a UTF-8 string, base64-encode the raw digest, and compare it with `X-SignNow-Signature` in constant time. SignNow's PHP sample computes a hex digest and converts it to base64, which gives the same value.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifySignNowSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  // base64 of the raw digest, not hex. Base64 is case-sensitive: don't lowercase it.
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');

  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/signnow', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.SIGNNOW_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifySignNowSignature(req.body, req.get('x-signnow-signature'), secret)) {
    return res.sendStatus(401);
  }

  const callback = JSON.parse(req.body.toString('utf8'));
  // Route on callback.meta.event. Deduplicate on event + entity ID + meta.timestamp.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_signnow_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_signnow_signature(raw_body, request.headers.get("x-signnow-signature"),
#                          os.environ["SIGNNOW_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `MySecretKey` and this exact body:

```
{"meta":{"timestamp":1728310375,"event":"document.complete"},"content":{"document_id":"3cd9758f093545089954894be9da90af210741b0"}}

```

The signature should be:

```
zcUS9p5uMwBbxv4tjBPKPsH0u1MUd+GiNQqDWv0PYcM=

```

> **Make SignNow webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-SignNow-Signature` header, answers SignNow inside its 20-second window, and durably queues every document and invite event before your service sees it.

## SignNow webhook limitations and pain points

### Unsigned unless you ask

**The Problem:** Callbacks arrive without `X-SignNow-Signature`, so they can't be verified.

**Why It Happens:** SignNow only signs callbacks for subscriptions created with a `secret_key`, and its own quick-start examples create subscriptions without one.

**Workarounds:**

* Include `secret_key` in every create request, and check new subscriptions in the API Dashboard.
* Reject callbacks without a valid signature once all subscriptions have a key.
* Recreate older subscriptions that were set up without a key.

**How Hookdeck Can Help:** Hookdeck's SignNow source verifies the header, and records unsigned or mis-signed callbacks as failed verification instead of delivering them to your service.

### Client errors can unsubscribe you

**The Problem:** Callbacks stop arriving entirely, and you find a cancellation email.

**Why It Happens:** SignNow never retries a `4xx`. If it gets 10 or more in 10 minutes it sends a warning email, and at 30 or more in 60 minutes it unsubscribes you. An unreachable host gets 5 retries 10 seconds apart and up to 2 more 4 hours apart, then the subscription is cancelled. Only `5xx` errors never lead to unsubscribing.

**Workarounds:**

* Return `5xx`, not `4xx`, when the problem is on your side, such as a database outage.
* Make sure the warning emails reach someone who will act on them.
* After fixing the endpoint, reactivate or recreate the subscriptions, then recover missed events from the callbacks log.

**How Hookdeck Can Help:** Hookdeck accepts callbacks while your service is down or failing, so SignNow sees successful deliveries and never counts your errors towards unsubscribing. Hookdeck then retries delivery to your service on your own schedule.

### One subscription per event and entity

**The Problem:** Missing events, or the same event delivered several times.

**Why It Happens:** Each subscription covers one event for one entity. A forgotten event means a gap, and up to 20 active subscriptions can share an entity and event as long as their callback URLs differ, so overlaps produce duplicates.

**Workarounds:**

* Keep your subscription list in code and reconcile it against `GET /v2/event-subscriptions`.
* Prefer `user` subscriptions when you want everything a user sends, rather than one per document.
* Filter **Subscriptions** in the API Dashboard by event and entity to find extras.

**How Hookdeck Can Help:** With every subscription pointing at one Hookdeck source, you can see all SignNow callbacks in one place, and deduplication can drop the copies that overlapping subscriptions create.

### No event ID

**The Problem:** Nothing in a callback marks it as a retry or gives it a unique ID.

**Why It Happens:** Callbacks carry no event ID. SignNow's own advice is to build a key from `meta.timestamp`, the event name and the entity ID.

**Workarounds:**

* Deduplicate on `meta.event`, the entity ID from `content` and `meta.timestamp`.
* Make handlers idempotent, so a repeated `document.complete` does no harm.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated callbacks based on those same body fields.

## Best practices

### Verify against the raw body

The HMAC covers the exact bytes SignNow sent. In Express, use `express.raw({ type: '*/*' })` and parse after verification. In Next.js, read `await request.text()`. In FastAPI, read `await request.body()`.

### Handle both shapes of `meta.metadata`

With `include_metadata` on, `meta.metadata` is an object when the document has metadata and `[]` when it doesn't. Normalise it before reading fields.

### Limit 5xx retries where duplicates are costly

`retry_count` caps the retries SignNow makes for `5xx` errors at a value from 1 to 10. Lower it if a late duplicate is worse than a missed event, and rely on reconciliation for the rest.

### Recover from the callbacks log

`GET /v2/event-subscriptions/{subscription_id}/callbacks` lists the callbacks SignNow sent, with their response codes. Use it to find gaps after an outage, and compare against document status for anything critical.

### Acknowledge fast and process asynchronously

Each attempt times out after 20 seconds. Verify, store the callback, return `200`, then download documents or update records afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

SignNow event subscriptions send a `meta` and `content` callback for one event on one user, document or document group, and they are signed only if the subscription has a `secret_key`. Set one on every subscription, verify the base64 HMAC-SHA256 in `X-SignNow-Signature` against the raw body, route on `meta.event`, deduplicate on event, entity and timestamp, and avoid `4xx` responses, which SignNow never retries and which can unsubscribe you.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the SignNow signature, and holds each callback until your service has processed it, so your errors don't cost you a subscription.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle SignNow webhooks reliably in minutes.