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

# Webhooks

> Receive real-time HTTP callbacks when events occur in your DocuTrust account.

## Overview

Webhooks let your application receive real-time notifications when events happen in DocuTrust. Instead of polling the API, DocuTrust sends an HTTP POST request to your configured endpoint whenever a relevant event fires.

Each webhook delivery includes a JSON payload describing the event, a cryptographic signature for verification, and a unique delivery ID for deduplication.

## Supported Events

DocuTrust emits **14 event types** across four resource categories:

### Form Events

Triggered when a submitter interacts with a signing form.

| Event              | Description                                                                                                                                                                                                                                                                                                         |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `form.created`     | A new signing form was generated and assigned to a submitter.                                                                                                                                                                                                                                                       |
| `form.started`     | The submitter began filling out the form (first field interaction).                                                                                                                                                                                                                                                 |
| `form.viewed`      | The submitter opened the signing link.                                                                                                                                                                                                                                                                              |
| `form.completed`   | The submitter finished and submitted the form.                                                                                                                                                                                                                                                                      |
| `form.declined`    | The submitter declined to sign the document.                                                                                                                                                                                                                                                                        |
| `form.expired`     | The form's expiration date passed without completion.                                                                                                                                                                                                                                                               |
| `signing.complete` | **Identity-bound only.** Fires after `form.completed` when the submission was signed via the SpitShake Identity Engine handoff (JWT with `verified_name` + `stripe_verification_id`). Payload includes an `identity` block with the verified tuple. See [Identity-bound signing](/features/identity-bound-signing). |

### Field Events

| Event           | Description                                         |
| --------------- | --------------------------------------------------- |
| `field.updated` | A submitter changed the value of a field in a form. |

### Submission Events

Triggered when the overall submission (containing one or more submitters) changes state.

| Event                  | Description                                                  |
| ---------------------- | ------------------------------------------------------------ |
| `submission.created`   | A new submission was created from a template.                |
| `submission.completed` | All submitters in the submission have completed their forms. |
| `submission.archived`  | The submission was archived by an admin.                     |
| `submission.expired`   | The submission expired before all submitters completed.      |

### Template Events

| Event               | Description                                                |
| ------------------- | ---------------------------------------------------------- |
| `template.created`  | A new template was created.                                |
| `template.updated`  | A template's fields, documents, or settings were modified. |
| `template.archived` | A template was archived.                                   |

## Event Payloads

Every webhook delivery is an HTTP POST with `Content-Type: application/json`. The top-level structure is always:

```json theme={null}
{
  "event": "event.type",
  "timestamp": "ISO-8601 timestamp",
  "data": { }
}
```

### Form Event Payload

All form events (`form.created`, `form.started`, `form.viewed`, `form.completed`, `form.declined`, `form.expired`) share this structure:

```json theme={null}
{
  "event": "form.completed",
  "timestamp": "2026-04-09T14:32:00Z",
  "data": {
    "submitter": {
      "id": 142,
      "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "email": "jane.doe@example.com",
      "name": "Jane Doe",
      "role": "First Party",
      "status": "completed",
      "external_id": "cust_12345",
      "metadata": {
        "department": "Legal"
      },
      "opened_at": "2026-04-09T14:20:00Z",
      "completed_at": "2026-04-09T14:32:00Z",
      "declined_at": null
    },
    "submission": {
      "id": 89,
      "slug": "abc123def456",
      "status": "completed",
      "template_id": 15
    },
    "template": {
      "id": 15,
      "name": "Employment Agreement"
    }
  }
}
```

<Note>
  For `form.declined` events, the `declined_at` field will contain a timestamp and `completed_at` will be `null`. For `form.expired` events, both `completed_at` and `declined_at` will be `null`.
</Note>

### Submission Event Payload

Submission events include the full list of submitters and their statuses:

```json theme={null}
{
  "event": "submission.completed",
  "timestamp": "2026-04-09T14:35:00Z",
  "data": {
    "submission": {
      "id": 89,
      "slug": "abc123def456",
      "status": "completed",
      "created_at": "2026-04-09T10:00:00Z",
      "completed_at": "2026-04-09T14:35:00Z",
      "template_id": "tmpl_7VQhP2tM9xA1kR8bN",
      "metadata": {
        "partner_reference": "case_8675309"
      },
      "submitters": [
        {
          "id": 142,
          "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
          "email": "jane.doe@example.com",
          "name": "Jane Doe",
          "role": "First Party",
          "status": "completed",
          "external_id": "signer_12345"
        },
        {
          "id": 143,
          "uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
          "email": "john.smith@acme.com",
          "name": "John Smith",
          "role": "Second Party",
          "status": "completed",
          "external_id": null
        }
      ],
      "documents": [
        {
          "id": 301,
          "document_type": "signed_pdf",
          "filename": "employment-agreement.pdf",
          "download_url": "https://your-instance.com/api/v1/submissions/89/documents/301"
        }
      ]
    },
    "template": {
      "id": 15,
      "name": "Employment Agreement"
    }
  }
}
```

Submission event `metadata` echoes the caller-provided submission metadata. Keys reserved for
DocuTrust internals, currently `flattening_failed`, are omitted. Submitter metadata remains
excluded; use each submitter's `external_id` for caller-owned correlation.

### Document download URLs in payloads

The `submission.completed` and `form.completed` webhook payloads include a `documents` array with download URLs, so you can retrieve signed PDFs directly from the event without a follow-up API call:

```json theme={null}
{
  "event": "submission.completed",
  "data": {
    "submission": {
      "id": 42,
      "status": "completed",
      "documents": [
        {
          "id": 1,
          "document_type": "signed_pdf",
          "filename": "signed_contract.pdf",
          "download_url": "https://your-instance.com/api/v1/submissions/42/documents/1"
        },
        {
          "id": 2,
          "document_type": "combined",
          "filename": "complete_contract.pdf",
          "download_url": "https://your-instance.com/api/v1/submissions/42/documents/2"
        }
      ]
    }
  }
}
```

The `download_url` requires authentication (Bearer token or X-Auth-Token with `submissions:read` scope).

### Template Event Payload

Template events include metadata about the template's structure:

```json theme={null}
{
  "event": "template.updated",
  "timestamp": "2026-04-09T09:15:00Z",
  "data": {
    "template": {
      "id": 15,
      "name": "Employment Agreement",
      "slug": "employment-agreement-v2",
      "external_id": "tmpl_emp_2026",
      "folder_name": "HR Documents",
      "source": "web",
      "created_at": "2026-03-01T08:00:00Z",
      "updated_at": "2026-04-09T09:15:00Z",
      "submitters": [
        {
          "uuid": "c3d4e5f6-a7b8-9012-cdef-123456789012",
          "name": "Employee"
        },
        {
          "uuid": "d4e5f6a7-b8c9-0123-defa-234567890123",
          "name": "HR Manager"
        }
      ],
      "field_count": 12,
      "document_count": 1
    }
  }
}
```

### Field Updated Payload

Fired each time a submitter changes a field value. Includes the old and new values for change tracking:

```json theme={null}
{
  "event": "field.updated",
  "timestamp": "2026-04-09T14:25:00Z",
  "data": {
    "event": "field.updated",
    "submitter": {
      "id": 142,
      "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "email": "jane.doe@example.com",
      "name": "Jane Doe",
      "role": "First Party"
    },
    "submission": {
      "id": 89,
      "slug": "abc123def456",
      "template_id": 15
    },
    "field": {
      "uuid": "f1a2b3c4-d5e6-7890-abcd-ef1234567890",
      "name": "Start Date",
      "type": "date",
      "old_value": null,
      "new_value": "2026-05-01",
      "was_prefilled": false
    },
    "changed_at": "2026-04-09T14:25:00Z"
  }
}
```

## Webhook Headers

Every webhook delivery includes the following HTTP headers:

| Header                  | Description                                                            | Example                                |
| ----------------------- | ---------------------------------------------------------------------- | -------------------------------------- |
| `Content-Type`          | Always JSON.                                                           | `application/json`                     |
| `X-Webhook-Signature`   | HMAC-SHA256 signature of the raw request body.                         | `sha256=5d7b1e3a9f...`                 |
| `X-Webhook-Secret`      | The shared secret configured for this webhook endpoint.                | `whsec_abc123def456`                   |
| `X-Webhook-Delivery-Id` | Unique UUID identifying this delivery attempt (use for deduplication). | `e7f8a9b0-c1d2-3456-e7f8-a9b0c1d23456` |
| `User-Agent`            | Identifies the sender.                                                 | `DocuTrust-Webhook/1.0`                |

## HMAC Signature Verification

Every webhook includes an `X-Webhook-Signature` header containing an HMAC-SHA256 digest of the raw request body, signed with your webhook secret key:

```
X-Webhook-Signature: sha256=HMAC-SHA256(secret_key, raw_body)
```

**Always verify the signature** before processing a webhook to ensure it was sent by DocuTrust and was not tampered with in transit.

<CodeGroup>
  ```ruby Ruby theme={null}
  require 'openssl'

  def verify_webhook(request, secret)
    raw_body = request.body.read
    signature_header = request.headers['X-Webhook-Signature']

    expected = 'sha256=' + OpenSSL::HMAC.hexdigest(
      OpenSSL::Digest.new('sha256'),
      secret,
      raw_body
    )

    if Rack::Utils.secure_compare(expected, signature_header)
      JSON.parse(raw_body)
    else
      raise 'Invalid webhook signature'
    end
  end
  ```

  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifyWebhook(req, secret) {
    const rawBody = req.body; // Buffer from express.raw()
    const signatureHeader = req.headers['x-webhook-signature'];

    const expected =
      'sha256=' +
      crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

    const isValid = crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signatureHeader)
    );

    if (!isValid) {
      throw new Error('Invalid webhook signature');
    }

    return JSON.parse(rawBody);
  }
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import json

  def verify_webhook(request_body: bytes, signature_header: str, secret: str) -> dict:
      expected = 'sha256=' + hmac.new(
          secret.encode('utf-8'),
          request_body,
          hashlib.sha256
      ).hexdigest()

      if not hmac.compare_digest(expected, signature_header):
          raise ValueError('Invalid webhook signature')

      return json.loads(request_body)
  ```
</CodeGroup>

<Warning>
  Always use the **raw request body** (bytes) for signature verification, not a parsed or re-serialized version. Re-serializing JSON may change key order or whitespace, causing verification to fail.
</Warning>

## Managing Webhooks via API

### Create a Webhook Endpoint

```bash theme={null}
curl -X POST https://your-instance.spitshake.io/api/webhooks \
  -H "X-Auth-Token: YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/docutrust",
    "events": [
      "form.completed",
      "form.declined",
      "submission.completed",
      "submission.expired"
    ],
    "secret": "whsec_your_signing_secret_here",
    "enabled": true
  }'
```

**Response:**

```json theme={null}
{
  "id": 34,
  "url": "https://yourapp.com/webhooks/docutrust",
  "events": [
    "form.completed",
    "form.declined",
    "submission.completed",
    "submission.expired"
  ],
  "secret": "whsec_your_signing_secret_here",
  "enabled": true,
  "created_at": "2026-04-09T10:00:00Z",
  "updated_at": "2026-04-09T10:00:00Z"
}
```

### Update a Webhook Endpoint

```bash theme={null}
curl -X PUT https://your-instance.spitshake.io/api/webhooks/34 \
  -H "X-Auth-Token: YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/docutrust-v2",
    "events": [
      "form.completed",
      "form.declined",
      "form.expired",
      "submission.completed",
      "submission.expired",
      "template.updated"
    ],
    "enabled": true
  }'
```

**Response:**

```json theme={null}
{
  "id": 34,
  "url": "https://yourapp.com/webhooks/docutrust-v2",
  "events": [
    "form.completed",
    "form.declined",
    "form.expired",
    "submission.completed",
    "submission.expired",
    "template.updated"
  ],
  "secret": "whsec_your_signing_secret_here",
  "enabled": true,
  "created_at": "2026-04-09T10:00:00Z",
  "updated_at": "2026-04-09T11:30:00Z"
}
```

### List All Webhook Endpoints

```bash theme={null}
curl -X GET https://your-instance.spitshake.io/api/webhooks \
  -H "X-Auth-Token: YOUR_API_TOKEN"
```

**Response:**

```json theme={null}
{
  "data": [
    {
      "id": 34,
      "url": "https://yourapp.com/webhooks/docutrust-v2",
      "events": [
        "form.completed",
        "form.declined",
        "form.expired",
        "submission.completed",
        "submission.expired",
        "template.updated"
      ],
      "secret": "whsec_your_signing_secret_here",
      "enabled": true,
      "created_at": "2026-04-09T10:00:00Z",
      "updated_at": "2026-04-09T11:30:00Z"
    }
  ],
  "pagination": {
    "count": 1,
    "next": null,
    "prev": null
  }
}
```

### Delete a Webhook Endpoint

```bash theme={null}
curl -X DELETE https://your-instance.spitshake.io/api/webhooks/34 \
  -H "X-Auth-Token: YOUR_API_TOKEN"
```

Returns HTTP `204 No Content` on success.

### Webhook Delivery Log

Retrieve the delivery history for a specific webhook endpoint. Each entry includes the HTTP status code, response time, payload, and any error details.

```bash theme={null}
curl -X GET https://your-instance.spitshake.io/api/webhooks/34/deliveries \
  -H "X-Auth-Token: YOUR_API_TOKEN"
```

**Query parameters:**

| Parameter | Type    | Default | Description                                                      |
| --------- | ------- | ------- | ---------------------------------------------------------------- |
| `status`  | string  | --      | Filter by delivery status: `"success"`, `"failed"`, `"pending"`. |
| `event`   | string  | --      | Filter by event type (e.g., `"form.completed"`).                 |
| `after`   | integer | --      | Cursor for pagination (return items after this delivery ID).     |
| `before`  | integer | --      | Cursor for pagination (return items before this delivery ID).    |
| `limit`   | integer | `25`    | Items per page (1-100).                                          |

**Response:**

```json theme={null}
{
  "data": [
    {
      "id": 1892,
      "webhook_id": 34,
      "event": "form.completed",
      "status": "success",
      "http_status": 200,
      "url": "https://yourapp.com/webhooks/docutrust-v2",
      "request_headers": {
        "Content-Type": "application/json",
        "X-Webhook-Signature": "sha256=5d7b1e3a9f...",
        "X-Webhook-Secret": "whsec_abc123def456",
        "X-Webhook-Delivery-Id": "e7f8a9b0-c1d2-3456-e7f8-a9b0c1d23456",
        "User-Agent": "DocuTrust-Webhook/1.0"
      },
      "request_body": {
        "event": "form.completed",
        "timestamp": "2026-04-09T14:32:00Z",
        "data": {
          "submitter": {
            "id": 142,
            "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
            "email": "jane.doe@example.com",
            "name": "Jane Doe",
            "role": "First Party",
            "status": "completed",
            "external_id": "cust_12345",
            "metadata": {
              "department": "Legal"
            },
            "opened_at": "2026-04-09T14:20:00Z",
            "completed_at": "2026-04-09T14:32:00Z",
            "declined_at": null
          },
          "submission": {
            "id": 89,
            "slug": "abc123def456",
            "status": "completed",
            "template_id": 15
          },
          "template": {
            "id": 15,
            "name": "Employment Agreement"
          }
        }
      },
      "response_status": 200,
      "response_body": "{\"received\": true}",
      "response_time_ms": 142,
      "attempt": 1,
      "max_attempts": 6,
      "error_message": null,
      "delivered_at": "2026-04-09T14:32:01Z",
      "created_at": "2026-04-09T14:32:00Z"
    },
    {
      "id": 1885,
      "webhook_id": 34,
      "event": "form.viewed",
      "status": "failed",
      "http_status": null,
      "url": "https://yourapp.com/webhooks/docutrust-v2",
      "request_headers": {
        "Content-Type": "application/json",
        "X-Webhook-Signature": "sha256=8c4a2f1b7e...",
        "X-Webhook-Secret": "whsec_abc123def456",
        "X-Webhook-Delivery-Id": "f8a9b0c1-d2e3-4567-f8a9-b0c1d2e34567",
        "User-Agent": "DocuTrust-Webhook/1.0"
      },
      "request_body": {
        "event": "form.viewed",
        "timestamp": "2026-04-09T14:20:00Z",
        "data": {
          "submitter": {
            "id": 142,
            "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
            "email": "jane.doe@example.com",
            "name": "Jane Doe",
            "role": "First Party",
            "status": "opened",
            "external_id": "cust_12345",
            "metadata": {
              "department": "Legal"
            },
            "opened_at": "2026-04-09T14:20:00Z",
            "completed_at": null,
            "declined_at": null
          },
          "submission": {
            "id": 89,
            "slug": "abc123def456",
            "status": "pending",
            "template_id": 15
          },
          "template": {
            "id": 15,
            "name": "Employment Agreement"
          }
        }
      },
      "response_status": null,
      "response_body": null,
      "response_time_ms": null,
      "attempt": 3,
      "max_attempts": 6,
      "error_message": "Connection timed out after 5000ms",
      "delivered_at": null,
      "created_at": "2026-04-09T14:20:00Z"
    }
  ],
  "pagination": {
    "count": 2,
    "next": 1885,
    "prev": null
  }
}
```

**Delivery object fields:**

| Field              | Type            | Description                                                                                                |
| ------------------ | --------------- | ---------------------------------------------------------------------------------------------------------- |
| `id`               | integer         | Unique delivery identifier.                                                                                |
| `webhook_id`       | integer         | The webhook endpoint this delivery belongs to.                                                             |
| `event`            | string          | The event type that triggered this delivery.                                                               |
| `status`           | string          | Delivery status: `"success"`, `"failed"`, or `"pending"`.                                                  |
| `http_status`      | integer or null | The HTTP status code returned by your endpoint, or null if the request failed before receiving a response. |
| `url`              | string          | The URL the delivery was sent to.                                                                          |
| `request_headers`  | object          | The HTTP headers sent with the delivery.                                                                   |
| `request_body`     | object          | The full JSON payload sent in the request body.                                                            |
| `response_status`  | integer or null | The response HTTP status code.                                                                             |
| `response_body`    | string or null  | The response body (truncated to 1KB).                                                                      |
| `response_time_ms` | integer or null | Response time in milliseconds.                                                                             |
| `attempt`          | integer         | The attempt number (1 = first delivery, 2+ = retries).                                                     |
| `max_attempts`     | integer         | Maximum number of delivery attempts (always 6).                                                            |
| `error_message`    | string or null  | Error description if the delivery failed.                                                                  |
| `delivered_at`     | string or null  | ISO 8601 timestamp when the delivery was successfully received.                                            |
| `created_at`       | string          | ISO 8601 timestamp when the delivery was created.                                                          |

### Redelivering a failed webhook

If a delivery failed or was exhausted, you can trigger a redeliver:

```bash theme={null}
POST /api/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver
```

This creates a new delivery attempt with a fresh `delivery_uuid`.

### Test a Webhook Endpoint

Send a test delivery to verify your endpoint is working correctly.

```bash theme={null}
curl -X POST https://your-instance.spitshake.io/api/webhooks/34/test \
  -H "X-Auth-Token: YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "form.completed"
  }'
```

This sends a sample payload to your registered URL with realistic test data. The delivery includes all standard headers so you can verify your signature validation logic.

## Retry Policy

If your endpoint does not return an HTTP `2xx` response, DocuTrust retries the delivery up to **5 times** with increasing delays:

| Attempt   | Delay After Failure |
| --------- | ------------------- |
| 1st retry | 1 minute            |
| 2nd retry | 5 minutes           |
| 3rd retry | 30 minutes          |
| 4th retry | 2 hours             |
| 5th retry | 12 hours            |

After 5 failed attempts, the delivery is marked as permanently failed. You can view failed deliveries in the webhook logs section of your account settings.

**Timeout thresholds:**

* **Connect timeout:** 5 seconds. If DocuTrust cannot establish a TCP connection within 5 seconds, the attempt fails.
* **Read timeout:** 10 seconds. If your server does not send a complete response within 10 seconds of connection, the attempt fails.

Each retry includes the same `X-Webhook-Delivery-Id`, so you can use it to deduplicate deliveries on your end.

<Tip>
  Return a `200 OK` response as quickly as possible. Process the webhook payload asynchronously (e.g., via a background job) to avoid hitting the read timeout.
</Tip>

## SSRF Protection

DocuTrust validates all webhook URLs before delivering events. The following restrictions apply to protect against Server-Side Request Forgery (SSRF) attacks:

**Blocked IP ranges:**

| CIDR Block       | Description                              |
| ---------------- | ---------------------------------------- |
| `10.0.0.0/8`     | Private network (Class A)                |
| `172.16.0.0/12`  | Private network (Class B)                |
| `192.168.0.0/16` | Private network (Class C)                |
| `127.0.0.0/8`    | Loopback (localhost)                     |
| `169.254.0.0/16` | Link-local / cloud metadata              |
| `100.64.0.0/10`  | Carrier-grade NAT (shared address space) |

**Additional restrictions:**

* **HTTPS required in production.** HTTP URLs are only accepted in development/test environments.
* **Localhost blocked.** URLs containing `localhost`, `127.0.0.1`, or `[::1]` are rejected.
* **Cloud metadata endpoints blocked.** URLs targeting `169.254.169.254` and similar metadata service IPs are rejected.

## Best Practices

<AccordionGroup>
  <Accordion title="Verify signatures on every request">
    Always validate the `X-Webhook-Signature` header before processing any webhook payload. Never skip verification, even in development.
  </Accordion>

  <Accordion title="Use the delivery ID for deduplication">
    Store the `X-Webhook-Delivery-Id` header value and check for duplicates before processing. Retries will send the same delivery ID.
  </Accordion>

  <Accordion title="Respond quickly, process asynchronously">
    Return a `200` status immediately and enqueue the payload for background processing. Long-running synchronous handlers risk timeouts and unnecessary retries.
  </Accordion>

  <Accordion title="Handle events idempotently">
    Your webhook handler may receive the same event more than once due to retries. Design your processing logic to be safe for repeated execution.
  </Accordion>

  <Accordion title="Subscribe only to events you need">
    Selecting specific events reduces traffic to your endpoint and simplifies your handler logic. You can always add more events later.
  </Accordion>

  <Accordion title="Monitor your endpoint health">
    Set up alerting for elevated error rates or slow response times on your webhook handler. DocuTrust will stop retrying after 5 failures.
  </Accordion>
</AccordionGroup>
