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

# Webhooks

> React to signing events in real time. Configure your endpoint, verify signatures, and handle retries.

## Overview

SignProof fires webhooks when significant events happen on your envelopes. Your backend receives
an HTTP `POST` with a JSON payload. You control what happens next — SignProof doesn't know or care.

***

## Configure a webhook endpoint

```bash theme={null}
curl -X POST https://api.thesignproof.com/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/getsigned",
    "events": ["envelope.completed", "envelope.declined", "envelope.voided", "envelope.expired"],
    "secret": "your-webhook-signing-secret"
  }'
```

`secret` is used to sign the payload (HMAC-SHA256). Store it in your secrets manager.

***

## Event types

| Event                | When it fires                                         |
| -------------------- | ----------------------------------------------------- |
| `envelope.completed` | All signers have signed. Sealed PDF is ready.         |
| `envelope.declined`  | A signer declined to sign.                            |
| `envelope.voided`    | You voided the envelope.                              |
| `envelope.expired`   | The signing deadline passed.                          |
| `signer.viewed`      | A signer opened their signing link.                   |
| `signer.signed`      | An individual signer completed signing.               |
| `signer.otp_failed`  | A signer failed OTP verification (3 failed attempts). |

***

## Payload format

```json theme={null}
{
  "event": "envelope.completed",
  "id": "evt_01HX...",
  "createdAt": "2026-06-19T14:32:00Z",
  "data": {
    "envelopeId": "env_01HX...",
    "tenantId": "acme-corp",
    "subject": "Service Agreement",
    "completedAt": "2026-06-19T14:32:00Z",
    "documentUrl": "https://api.thesignproof.com/v1/envelopes/env_01HX.../document",
    "signers": [
      {
        "id": "sgn_01HX...",
        "name": "Jane Smith",
        "email": "jane@example.com",
        "signedAt": "2026-06-19T14:31:45Z"
      }
    ]
  }
}
```

***

## Verifying the webhook signature

Every webhook request includes a `X-SignProof-Signature` header containing an HMAC-SHA256
signature of the raw request body, computed using your webhook `secret`.

**Always verify this signature before processing the payload.** This prevents attackers from
spoofing events.

<CodeGroup>
  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_signature(payload_bytes: bytes, header: str, secret: str) -> bool:
      expected = hmac.new(
          secret.encode("utf-8"),
          payload_bytes,
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(f"sha256={expected}", header)
  ```

  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "crypto";

  function verifySignature(payload: Buffer, header: string, secret: string): boolean {
    const expected = "sha256=" + createHmac("sha256", secret).update(payload).digest("hex");
    return timingSafeEqual(Buffer.from(expected), Buffer.from(header));
  }
  ```

  ```csharp C# theme={null}
  using System.Security.Cryptography;
  using System.Text;

  bool VerifySignature(byte[] payload, string header, string secret)
  {
      var key = Encoding.UTF8.GetBytes(secret);
      using var hmac = new HMACSHA256(key);
      var hash = "sha256=" + Convert.ToHexString(hmac.ComputeHash(payload)).ToLower();
      return CryptographicOperations.FixedTimeEquals(
          Encoding.UTF8.GetBytes(hash),
          Encoding.UTF8.GetBytes(header));
  }
  ```
</CodeGroup>

<Warning>
  Use a **constant-time comparison** (`hmac.compare_digest`, `timingSafeEqual`,
  `FixedTimeEquals`). A naive `==` comparison leaks timing information that can be exploited.
</Warning>

***

## Responding to webhooks

Your endpoint must return `2xx` within **10 seconds**. If it times out or returns a non-2xx status,
SignProof will retry.

**Do not do heavy work inside the webhook handler.** Acknowledge receipt immediately and process
asynchronously:

```python theme={null}
@app.post("/webhooks/getsigned")
async def handle_webhook(request: Request):
    body = await request.body()
    sig = request.headers.get("X-SignProof-Signature")
    if not verify_signature(body, sig, WEBHOOK_SECRET):
        raise HTTPException(status_code=401)

    payload = json.loads(body)
    await queue.enqueue("process_getsigned_event", payload)  # async queue

    return {"received": True}  # 200 immediately
```

***

## Retry schedule

If your endpoint doesn't return `2xx` within 10 seconds, SignProof retries with exponential backoff:

| Attempt | Delay      |
| ------- | ---------- |
| 1       | Immediate  |
| 2       | 1 minute   |
| 3       | 10 minutes |
| 4       | 1 hour     |
| 5       | 6 hours    |

After 5 failed attempts, the delivery is marked as failed. You can view failed deliveries and
trigger a manual resend in the Console under **Webhooks → Deliveries**.

***

## Idempotency

Webhooks may be delivered more than once (network retries can cause duplicates even after a
successful delivery). Use the `id` field (`evt_01HX...`) to deduplicate:

```python theme={null}
if await redis.exists(f"webhook:{payload['id']}"):
    return {"received": True}  # already processed

await redis.setex(f"webhook:{payload['id']}", 86400, "1")
await process(payload)
```

***

## Testing webhooks locally

Use [ngrok](https://ngrok.com) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) to
expose your local server:

```bash theme={null}
ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000

# Register your ngrok URL as the webhook endpoint
curl -X POST https://api.thesignproof.com/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"url": "https://abc123.ngrok.io/webhooks/getsigned", "events": ["envelope.completed"]}'
```
