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

# Webhooks

> Implement secure webhook integrations to receive real-time event notifications from Firma.

Firma sends webhook events to notify your application about signing lifecycle changes, document completions, and workspace activities. Webhooks enable real-time integrations without polling.

## Common use cases

* Send internal notifications when documents are signed
* Update your database when signing requests are completed
* Trigger downstream workflows (invoicing, provisioning, etc.)
* Track signing request status changes in real-time

## Event types

Firma sends the following event types:

### Signing Request Events

* `signing_request.created` - New signing request created
* `signing_request.sent` - Signing request sent to recipients
* `signing_request.viewed` - Recipient viewed the document
* `signing_request.completed` - All recipients finished signing
* `signing_request.expired` - Signing request expired
* `signing_request.cancelled` - Signing request was cancelled
* `signing_request.updated` - Signing request metadata updated
* `signing_request.certificate.generated` - Signing certificate generated
* `signing_request.reminder.sent` - Reminder sent to recipients
* `signing_request.field.filled` - A field was filled by a recipient
* `signing_request.document.updated` - Document was updated

### Recipient Events

* `signing_request.recipient.added` - Recipient added to signing request
* `signing_request.recipient.signed` - Recipient completed signing
* `signing_request.recipient.declined` - Recipient declined to sign
* `signing_request.recipient.updated` - Recipient details updated
* `signing_request.recipient.identity_changed` - Recipient changed their identity (name, company, etc.) during signing

### Template Events

* `template.created` - New template created
* `template.updated` - Template modified
* `template.deleted` - Template deleted
* `template.field.added` - Field added to template
* `template.used` - Template used to create a signing request

### Workspace Events

* `workspace.created` - New workspace created
* `workspace.updated` - Workspace modified
* `workspace.deleted` - Workspace deleted

### Domain Events

* `domain.verified` - Domain verified successfully
* `domain.verification.failed` - Domain verification failed

## Creating a webhook

Create webhooks via the API or dashboard:

```bash theme={null}
curl -X POST "https://api.firma.dev/functions/v1/signing-request-api/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/firma",
    "events": [
      "signing_request.completed",
      "signing_request.recipient.signed"
    ],
    "description": "Production webhook for signing events"
  }'
```

<Note>
  Your webhook URL must use HTTPS and respond within 5 seconds. Firma will send a test event during creation to verify the endpoint.
</Note>

## Webhook payload structure

All webhook events follow this standard structure:

```json theme={null}
{
  "id": "evt_1707500000_k8f2m9x3a",
  "type": "signing_request.completed",
  "created_at": "2025-10-03T14:30:00Z",
  "company_id": "comp_123",
  "workspace_id": "ws_456",
  "data": {
    "signing_request": {
      "id": "sr_789",
      "name": "NDA - Acme Corp",
      "workspace_id": "ws_456",
      "created_at": "2025-10-01T10:00:00Z",
      "sent_at": "2025-10-01T10:05:00Z",
      "completed_at": "2025-10-03T14:29:55Z",
      "cancelled_at": null,
      "expires_at": "2025-10-08T10:05:00Z",
      "document_page_count": 3
    },
    "recipients": [
      {
        "id": "rec_abc",
        "email": "alice@example.com",
        "name": "Alice Johnson",
        "first_name": "Alice",
        "last_name": "Johnson",
        "order": 1,
        "designation": "Signer",
        "signed_at": "2025-10-03T14:29:55Z"
      }
    ],
    "workspace": {
      "id": "ws_456",
      "name": "Legal"
    }
  }
}
```

## Security: Signature verification (Required)

<Warning>
  **Always verify webhook signatures** to prevent spoofing attacks. Do not process webhooks without signature verification.
</Warning>

Firma signs all webhook requests using HMAC SHA-256. Your webhook endpoint receives these headers:

* `X-Firma-Signature` - HMAC signature using current signing secret
* `X-Firma-Signature-Old` - HMAC signature using previous secret (during 24-hour rotation grace period)
* `X-Firma-Event` - Event type (e.g., `signing_request.completed`)
* `X-Firma-Delivery` - Unique delivery attempt ID

### Signature format

Firma uses a timestamped signature header:

* Header: `X-Firma-Signature: t=1707500000,v1=abc123def456...`
* `t` = Unix timestamp (seconds) when the signature was generated
* `v1` = HMAC-SHA256 hex digest
* Signed payload format: `{timestamp}.{json_body}`

Example:

```
1707500000.{"id":"evt_123","type":"signing_request.completed",...}
```

### Get your signing secret

1. Navigate to your [Firma dashboard](https://app.firma.dev/settings)
2. View webhook details to retrieve the signing secret
3. Store the secret securely (environment variable or secrets manager)

### Verification example — Node.js (Express)

```js theme={null}
import express from 'express'
import crypto from 'crypto'

const app = express()

// Use raw body for signature verification
app.post('/webhooks/firma', 
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signatureHeader = req.headers['x-firma-signature']
    const signatureHeaderOld = req.headers['x-firma-signature-old']
    const payload = req.body.toString('utf8')
    
    const signingSecret = process.env.FIRMA_WEBHOOK_SECRET
    
    // Verify with current secret
    if (!verifySignature(payload, signatureHeader, signingSecret)) {
      // During secret rotation, also check old signature header
      if (!signatureHeaderOld || !verifySignature(payload, signatureHeaderOld, signingSecret)) {
        console.error('Invalid webhook signature')
        return res.status(401).json({ error: 'Invalid signature' })
      }
    }
    
    // Parse and process event
    const event = JSON.parse(payload)
    processWebhookEvent(event)
    
    // Respond immediately
    res.status(200).json({ received: true })
  }
)

function verifySignature(payload, signatureHeader, secret) {
  if (!signatureHeader) return false

  // 1. Parse signature header: "t=1707500000,v1=abc123..."
  const parts = {}
  signatureHeader.split(',').forEach(part => {
    const [key, value] = part.split('=')
    parts[key] = value
  })

  const timestamp = parts.t
  const signature = parts.v1

  if (!timestamp || !signature) return false

  // 2. Optional: Reject old timestamps to prevent replay attacks (5 min tolerance)
  const timestampAge = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)
  if (timestampAge > 300) {
    console.warn('Webhook timestamp too old:', timestampAge, 'seconds')
    return false
  }

  // 3. Reconstruct the signed payload (timestamp + "." + raw JSON body)
  const signedPayload = `${timestamp}.${payload}`

  // 4. Compute expected signature
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex')

  // 5. Timing-safe comparison
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    )
  } catch {
    return false
  }
}

async function processWebhookEvent(event) {
  // Process asynchronously - don't block the response
  switch (event.type) {
    case 'signing_request.completed':
      await handleSigningCompleted(event.data)
      break
    case 'signing_request.recipient.signed':
      await handleRecipientSigned(event.data)
      break
    // ... handle other event types
  }
}

app.listen(3000)
```

### Verification example — Python (Flask)

```py theme={null}
from flask import Flask, request, jsonify
import hmac
import hashlib
import os
import time
import json

app = Flask(__name__)

@app.route('/webhooks/firma', methods=['POST'])
def firma_webhook():
    signature_header = request.headers.get('X-Firma-Signature')
    payload = request.get_data(as_text=True)
    
    signing_secret = os.environ['FIRMA_WEBHOOK_SECRET']
    
    # Verify signature
    if not verify_signature(payload, signature_header, signing_secret):
      return jsonify({'error': 'Invalid signature'}), 401
    
    # Parse and process event
    event = json.loads(payload)
    process_webhook_event(event)
    
    # Respond immediately
    return jsonify({'received': True}), 200

def verify_signature(payload, signature_header, secret):
  if not signature_header:
    return False

  # Parse "t=1707500000,v1=abc123..."
  parts = dict(p.split('=') for p in signature_header.split(','))
  timestamp = parts.get('t')
  signature = parts.get('v1')

  if not timestamp or not signature:
    return False

  # Optional: Check timestamp age (5 min tolerance)
  if int(time.time()) - int(timestamp) > 300:
    return False

  # Reconstruct signed payload
  signed_payload = f"{timestamp}.{payload}"

  # Compute expected signature
  expected_signature = hmac.new(
    secret.encode('utf-8'),
    signed_payload.encode('utf-8'),
    hashlib.sha256
  ).hexdigest()

  return hmac.compare_digest(signature, expected_signature)

def process_webhook_event(event):
    # Process asynchronously - don't block the response
    event_type = event['type']
    
    if event_type == 'signing_request.completed':
        handle_signing_completed(event['data'])
    elif event_type == 'signing_request.recipient.signed':
        handle_recipient_signed(event['data'])
    # ... handle other event types

if __name__ == '__main__':
    app.run(port=3000)
```

## Handling secret rotation

When you rotate your webhook signing secret:

1. Firma generates a new secret
2. For 24 hours, Firma sends **both** signatures:
   * `X-Firma-Signature` (new secret)
   * `X-Firma-Signature-Old` (previous secret)
3. After 24 hours, only `X-Firma-Signature` is sent

**Implementation**: Check `X-Firma-Signature` first. If verification fails and `X-Firma-Signature-Old` exists, verify against the old secret.

## Retry behavior

Firma automatically retries failed webhook deliveries:

* **Retry schedule**: 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours
* **Total attempts**: Up to 5 retries per event
* **Timeout**: Your endpoint must respond within 5 seconds
* **Success**: Any 2xx status code indicates success
* **Auto-disable**: After 50 consecutive failures, the webhook is automatically disabled

<Tip>
  **Best practice**: Respond with 200 immediately, then process events asynchronously (queue, background job, etc.) to avoid timeouts.
</Tip>

## Idempotency

Always handle duplicate events using the event `id`:

```js theme={null}
async function processWebhookEvent(event) {
  // Check if already processed
  const exists = await db.webhookEvents.findOne({ event_id: event.id })
  if (exists) {
    console.log(`Event ${event.id} already processed`)
    return
  }
  
  // Store event id to prevent duplicates
  await db.webhookEvents.create({ event_id: event.id, processed_at: new Date() })
  
  // Process event
  // ...
}
```

## Monitoring webhook health

Monitor your webhook's health by checking the webhook details:

```bash theme={null}
## Get webhook details
curl "https://api.firma.dev/functions/v1/signing-request-api/webhooks/{id}" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Response includes:

* `consecutive_failures` - Number of consecutive failed deliveries
* `last_failure_at` - Timestamp of most recent failure
* `enabled` - Whether webhook is active (auto-disabled after 50 failures)
* `last_success_at` - Timestamp of most recent successful delivery

<Note>
  **Rate limit**: GET webhook requests support **60 requests per minute per API key**.
</Note>

## Troubleshooting

### Common issues

**401 Unauthorized / Invalid signature**

* Verify you're using the correct signing secret
* Check that you're hashing the raw request body (not parsed JSON)
* Ensure you're using HMAC SHA-256, not other hash algorithms

**Timeouts / 504 errors**

* Respond with 200 immediately, process asynchronously
* Check your endpoint responds within 5 seconds
* Use background jobs/queues for heavy processing

**Duplicate events**

* Implement idempotency using the event `id`
* Store processed event IDs in your database

**Webhook auto-disabled**

* Check `consecutive_failures` and recent event logs
* Fix endpoint issues, then re-enable webhook via API or dashboard

### Testing webhooks locally

Use a tunnel service like ngrok for local development:

```bash theme={null}
# Start ngrok
ngrok http 3000

# Use the HTTPS URL in your webhook configuration
# https://abc123.ngrok.io/webhooks/firma
```

## Production checklist

* Verify HMAC signatures on all webhook requests
* Handle signature rotation (check both `X-Firma-Signature` and `X-Firma-Signature-Old`)
* Respond with 200 within 5 seconds
* Process events asynchronously (queues/background jobs)
* Implement idempotency using the event `id`
* Store signing secret securely (env var or secrets manager)
* Monitor `consecutive_failures` metric via GET webhook endpoint
* Set up alerts for webhook failures
* Log all webhook events for debugging
* Test with all subscribed event types
* Stay within rate limits (See [Rate Limit Guide](/guides/rate-limits))

## Next steps

* [Create a webhook](../api-reference/v01.15.00/webhooks/create-webhook) via API
* [Test your webhook](../api-reference/v01.15.00/webhooks/test-a-webhook) before going live
* [Update webhook settings](../api-reference/v01.15.00/webhooks/update-webhook) as needed
