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

# Webhook Integration

> Complete guide to integrating webhooks for verification notifications

# Webhook Integration Guide

<p>
  <a href="/assets/downloads/guides/integration.pdf" download="integration.pdf">Download this guide</a>
</p>

The SNH AI Background Check API provides comprehensive webhook support to receive real-time notifications throughout the verification lifecycle. This guide covers practical webhook configuration, security implementation, payload handling, and complete integration examples.

For a high-level overview of the webhook system and when events are triggered, see the [Webhook System Overview](/webhooks/overview).

## Delivery model

Webhooks are signed and retried according to `retryAttempts`. These delivery mechanics — retries, signing, and backoff — are identical in sandbox and production; the delivery service has no environment-conditional logic for them. Any "best-effort" feel to sandbox testing is a function of how you configure your own test organization and webhook receiver, not a platform guarantee that differs by environment. See [Environments](/getting-started/environments).

## Overview

Webhooks are HTTP callbacks that notify your application when verification events occur. The API sends POST requests to your configured endpoints with event payloads and HMAC-SHA256 signatures for security verification.

## Illustrative terminal payloads (`verification.completed`)

Structures vary by organization program; illustrative fragments only:

**Employment (verified)**

```json theme={null}
{
  "event": "verification.completed",
  "data": {
    "searchType": "EMPLOYMENT",
    "verificationResult": {
      "outcome": "VERIFIED",
      "employmentDates": {
        "startDate": "2020-01-15",
        "endDate": "2023-06-30"
      },
      "positionTitle": "Software Engineer",
      "salaryBand": { "currency": "USD", "max": 150000 }
    }
  }
}
```

**Education (verified)**

```json theme={null}
{
  "event": "verification.completed",
  "data": {
    "searchType": "EDUCATION",
    "verificationResult": {
      "outcome": "VERIFIED",
      "degree": "BS Computer Science",
      "completionDate": "2018-05-15",
      "gpa": "3.7"
    }
  }
}
```

**Action required (third-party / manual)**

```json theme={null}
{
  "event": "verification.action_required",
  "data": {
    "reasonCode": "THIRD_PARTY_RECORD",
    "searchType": "EMPLOYMENT",
    "metadata": { "routingHint": "contact_verifier_via_portal" }
  }
}
```

Complete field lists: [Webhook events](/webhooks/events).

## Webhook Events

The API sends three types of webhook events:

* **`verification.completed`** - Sent when a verification search reaches a terminal outcome (VERIFIED, NO\_RECORD, WRONG\_ORG, THIRD\_PARTY, etc., per your program)
* **`verification.action_required`** - Sent when the verification cannot proceed without action (THIRD\_PARTY\_RECORD, UPSTREAM\_ISSUE, SYSTEM\_FAILURE, SLA\_REACHED, HUMAN\_ESCALATION, OTHER)
* **`verification.notification`** - Intermediate progress updates (contact plan, outbound attempts, inbound messages) when those notifications are enabled for your routing

For complete details on event types, payload structures, and when events are triggered, see [Webhook Events](/webhooks/events).

## Webhook Configuration

Webhooks can be configured at two levels:

### Request-Level Configuration

Configure webhooks per verification request:

```json theme={null}
{
  "applicant": {
    /* ... */
  },
  "searchTypes": [
    /* ... */
  ],
  "webhookConfig": {
    "enabled": true,
    "secret": "your-webhook-secret-key",
    "retryAttempts": 3,
    "closeoutEndpoints": {
      "EMPLOYMENT": [
        {
          "url": "https://your-app.com/webhooks/employment",
          "headers": { "X-Customer": "acme" },
          "events": ["verification.completed"]
        },
        {
          "url": "https://your-app.com/webhooks/employment-notify",
          "events": ["verification.notification"]
        }
      ],
      "EDUCATION": [
        {
          "url": "https://your-app.com/webhooks/education",
          "basicAuth": { "username": "api", "password": "secret" },
          "events": ["verification.completed"]
        }
      ]
    },
    "fallbackEndpoint": [
      {
        "url": "https://your-app.com/webhooks/default",
        "events": ["verification.action_required"],
        "searchTypes": ["EMPLOYMENT", "EDUCATION"]
      }
    ]
  }
}
```

### Organization-level Configuration

Configure webhooks at the organization level (applies to all verifications unless overridden):

Contact your SNH AI account manager to configure organization-level webhook settings.

## Endpoint Resolution

Webhook configuration comes from two sources: your organization-level settings (stored against your account) and the request-level `webhookConfig` on a given order. These are **not** an all-or-nothing override — they are merged field by field:

* **`enabled`, `secret`, `retryAttempts`**: the request-level value is used if present, otherwise the organization-level value applies.
* **`closeoutEndpoints`**: merged per search type key. If the request supplies targets for `EMPLOYMENT` but not `EDUCATION`, the request's `EMPLOYMENT` targets fully replace the organization's `EMPLOYMENT` targets, while the organization's `EDUCATION` targets still apply — even though the request did supply a `closeoutEndpoints` object overall. Providing config for one search type never suppresses the organization's config for a different, unmentioned search type.
* **`fallbackEndpoint`**: replaced wholesale, not merged per-field. If the request supplies a `fallbackEndpoint`, it fully replaces the organization's fallback; otherwise the organization's fallback is used.

Once the config is merged, the API resolves delivery targets **per event and search type**, independently:

1. **Type-specific targets**: targets configured under `closeoutEndpoints[searchType]` that allow the event (`events` array) are included.
2. **Fallback targets**: targets under `fallbackEndpoint` that allow the event and, if the target sets `searchTypes`, include the resolved search type, are also included.

Type-specific and fallback targets are not mutually exclusive tiers — a fallback target can still receive an event alongside a type-specific target for the same search type if both match the event's filters. The API delivers to every matching target concurrently.

Notes:

* If `events` is omitted on a target, it will receive only `verification.completed` (backward compatible).
* Per-target `headers` are merged into the request headers. If `basicAuth` is set, an `Authorization: Basic ...` header is included.

## Webhook Payload Structure

All webhook payloads follow a consistent structure with `event`, `occurredAt`, and `data` fields. For detailed payload structures and examples, see [Webhook Events](/webhooks/events).

Terminal events (`verification.completed`, `verification.action_required`) always include a single `channel` field representing the primary verification channel. Intermediate notifications keep the original `channels` array to provide full auditing of concurrent communication paths.

## Webhook Headers

Each webhook request includes these headers:

| Header                 | Description                                                                                                                 |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`         | Always `application/json`.                                                                                                  |
| `X-Event-Type`         | Event type (`verification.completed`, `verification.action_required`, `verification.notification`).                         |
| `X-Event-Id`           | Unique delivery ID for this webhook (use for idempotency).                                                                  |
| `X-Search-Type`        | Optional; resolved search type: `EMPLOYMENT` or `EDUCATION`.                                                                |
| `X-External-Search-Id` | Optional; echoes the external search identifier provided when the order was created.                                        |
| `X-Endpoint-Source`    | Indicates whether the request used a `type-specific` or `fallback` endpoint.                                                |
| `User-Agent`           | Always `Theary-Webhook-Delivery/1.0`.[^1]                                                                                   |
| `Authorization`        | Present only when the webhook target specifies Basic Auth credentials (`Authorization: Basic <base64(username:password)>`). |
| `X-Webhook-Signature`  | Included when an organization-level or per-target secret is configured; value is `sha256=<hex>` for HMAC validation.        |

Notes:

* If neither the organization config nor the target defines a signing secret, `X-Webhook-Signature` is omitted.
* Per-target custom headers are merged into the request when configured (for example `X-Customer: acme`).

[^1]: "Theary" is the product/platform name reflected in this wire-level identifier. It refers to the same product as "SNH AI," the company name used elsewhere in these docs.

## Security: Signature Verification

All webhook payloads are signed using HMAC-SHA256. Verify the signature to ensure authenticity:

### Node.js Example

```javascript theme={null}
const crypto = require('crypto')

function verifyWebhookSignature(rawBody, signature, secret) {
  // Extract signature from "sha256=<hex>" format
  const signatureValue = signature.replace('sha256=', '')

  const expectedSignature = crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')

  return crypto.timingSafeEqual(Buffer.from(signatureValue, 'hex'), Buffer.from(expectedSignature, 'hex'))
}

// In your webhook handler
app.post('/webhooks/verification', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature']
  const secret = process.env.WEBHOOK_SECRET

  // Verify signature using raw body
  if (!verifyWebhookSignature(req.body.toString(), signature, secret)) {
    return res.status(401).json({ error: 'Invalid signature' })
  }

  // Parse JSON after verification
  const payload = JSON.parse(req.body.toString())

  // Process webhook
  res.status(200).json({ received: true })
})
```

### Bash / OpenSSL (compare hex digest)

Matches the server's `sha256=` hex computation when given the exact raw UTF-8 POST body (`$BODY`) and `$WEBHOOK_SECRET`:

```bash theme={null}
BODY='{"event":"verification.completed","occurredAt":"2026-01-01T00:00:00.000Z","data":{}}'
WEBHOOK_SECRET='your-webhook-secret-key'

HEX="$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $NF}')"
echo "Compare X-Webhook-Signature to: sha256=$HEX"
```

### Python Example

```python theme={null}
import hmac
import hashlib
import json
from flask import request

def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    # Extract signature from "sha256=<hex>" format
    signature_value = signature.replace('sha256=', '')

    expected_signature = hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature_value, expected_signature)

# In your webhook handler
@app.route('/webhooks/verification', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')
    secret = os.environ.get('WEBHOOK_SECRET')

    # Verify using raw request bytes to match server-side HMAC calculation
    raw_body = request.get_data()
    if not verify_webhook_signature(raw_body, signature, secret):
        return jsonify({'error': 'Invalid signature'}), 401

    # Process webhook
    return jsonify({'received': True}), 200
```

For detailed event payload structures and examples, see [Webhook Events](/webhooks/events).

## Common webhook mistakes

* **Skipping signature verification** — never trust unsigned or unvalidated payloads when a secret applies.
* **Assuming synchronous completion** — polls return before terminal outcomes; **`verification.completed`** is async.
* **Ignoring idempotency** — **`X-Event-Id`** can repeat across retries (`retryAttempts`); dedupe deliveries.
* **Missing event handlers** — implement **`verification.completed`**, **`verification.action_required`**, **`verification.notification`** as needed for your UX.

## Integration Examples

### Express.js Webhook Handler

```javascript theme={null}
const express = require('express')
const crypto = require('crypto')
const app = express()

// Middleware to capture raw body for signature verification
app.use('/webhooks/verification', express.raw({ type: 'application/json' }))

function verifySignature(rawBody, signature, secret) {
  // Extract signature from "sha256=<hex>" format
  const signatureValue = signature.replace('sha256=', '')

  const expectedSignature = crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')

  return crypto.timingSafeEqual(Buffer.from(signatureValue, 'hex'), Buffer.from(expectedSignature, 'hex'))
}

app.post('/webhooks/verification', (req, res) => {
  const signature = req.headers['x-webhook-signature']
  const secret = process.env.WEBHOOK_SECRET

  // Verify signature using raw body
  if (!verifySignature(req.body.toString(), signature, secret)) {
    console.error('Invalid webhook signature')
    return res.status(401).json({ error: 'Invalid signature' })
  }

  // Parse JSON after verification
  const payload = JSON.parse(req.body.toString())
  const { event, occurredAt, data } = payload

  // Handle different event types
  switch (event) {
    case 'verification.completed':
      handleVerificationCompleted(data)
      break
    case 'verification.action_required':
      handleVerificationActionRequired(data)
      break
    case 'verification.notification':
      handleVerificationNotification(data)
      break
    default:
      console.warn('Unknown event type:', event)
  }

  // Always respond with 200 to acknowledge receipt
  res.status(200).json({ received: true })
})

function handleVerificationCompleted(data) {
  const { searchId, verificationId, searchType, verificationResult } = data

  console.log(`Verification completed: ${searchId}`)
  console.log(`Outcome: ${verificationResult.outcome}`)
  console.log(`Search Type: ${searchType}`)

  // Update your database
  // Send notifications to your users
  // Trigger downstream processes
}

function handleVerificationActionRequired(data) {
  const { searchId, reasonCode, contact, metadata } = data

  console.warn(`Verification action required: ${searchId}`)
  console.warn(`Reason Code: ${reasonCode}`)
  if (contact) console.warn(`Contact: ${JSON.stringify(contact)}`)
  if (metadata) console.warn(`Metadata: ${JSON.stringify(metadata)}`)

  // Alert your team or end-user
  // Guide user to next steps (e.g., submit via third-party portal)
}

function handleVerificationNotification(data) {
  const { searchId, messageId, classification } = data

  console.log(`Notification received: ${searchId}`)
  console.log(`Classification: ${classification.type}`)

  // Update UI in real-time
  // Log notification for audit
}

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000')
})
```

### Flask Webhook Handler (Python)

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

app = Flask(__name__)

def verify_signature(raw_body, signature, secret):
    # Extract signature from "sha256=<hex>" format
    signature_value = signature.replace('sha256=', '')

    expected_signature = hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature_value, expected_signature)

@app.route('/webhooks/verification', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')
    secret = os.environ.get('WEBHOOK_SECRET')

    # Get raw body for signature verification
    raw_body = request.get_data()

    # Verify signature using raw body
    if not verify_signature(raw_body, signature, secret):
        return jsonify({'error': 'Invalid signature'}), 401

    # Parse JSON after verification
    payload = request.json
    event = payload.get('event')
    data = payload.get('data')

    # Handle different event types
    if event == 'verification.completed':
        handle_verification_completed(data)
    elif event == 'verification.action_required':
        handle_verification_action_required(data)
    elif event == 'verification.notification':
        handle_verification_notification(data)
    else:
        print(f'Unknown event type: {event}')

    return jsonify({'received': True}), 200

def handle_verification_completed(data):
    search_id = data.get('searchId')
    verification_result = data.get('verificationResult')

    print(f'Verification completed: {search_id}')
    print(f'Outcome: {verification_result.get("outcome")}')

    # Update your database
    # Send notifications
    # Trigger downstream processes

def handle_verification_action_required(data):
    search_id = data.get('searchId')
    reason_code = data.get('reasonCode')
    contact = data.get('contact')
    metadata = data.get('metadata')

    print(f'Action required: {search_id}')
    print(f'Reason: {reason_code}')
    if contact:
        print(f'Contact: {contact}')
    if metadata:
        print(f'Metadata: {metadata}')

def handle_verification_notification(data):
    search_id = data.get('searchId')
    classification = data.get('classification', {})

    print(f'Notification received: {search_id}')
    print(f'Classification: {classification.get("type")}')

    # Update UI in real-time

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

## Retry Logic

The API automatically retries failed webhook deliveries, identically in sandbox and production:

* **Retry attempts**: Configurable (default: 3, max: 10)
* **Backoff strategy**: Exponential backoff with jitter
* **Retry timing**: 2^attempt seconds (e.g., 2s, 4s, 8s)
* **Client errors (4xx)**: Not retried
* **Server errors (5xx)**: Retried with exponential backoff

Rely on this retry behavior for both environments when validating handlers and building alerting.

## Best Practices

### 1. Always Verify Signatures

Never process webhooks without verifying the HMAC signature. This ensures the webhook is from SNH AI and hasn't been tampered with.

### 2. Respond Quickly

Respond with HTTP 200 status code as soon as you receive the webhook. Perform heavy processing asynchronously.

### 3. Handle Idempotency

Webhooks may be retried, so ensure your handlers are idempotent. Use the `X-Event-Id` header to track processed events.

### 4. Use HTTPS Endpoints

Always use HTTPS endpoints for webhook delivery in production. Non-production environments may also accept `http://localhost` and `http://127.0.0.1` for local testing.

### 5. Monitor Webhook Delivery

Track delivery status and set up alerts for failed deliveries. Monitor your logs for webhook processing errors.

### 6. Store Secrets Securely

Never hardcode webhook secrets. Use environment variables or secure secret management services.

### 7. Test with Webhook Testing Tools

Use tools like ngrok or webhook.site to test your webhook endpoints during development.

## Error Handling

### HTTP Status Codes

Your webhook endpoint should return appropriate HTTP status codes:

* **200 OK**: Webhook received and processed successfully
* **400 Bad Request**: Invalid payload format (not retried)
* **401 Unauthorized**: Invalid signature (not retried)
* **500 Internal Server Error**: Server error (will be retried)

### Handling Failures

If your endpoint fails to process a webhook:

1. The API retries based on your `retryAttempts` configuration
2. After all retries are exhausted, delivery stops for that attempt
3. Make your handler idempotent with `X-Event-Id`, and consider a dead-letter queue for events you could not process
4. Prefer polling [Get specific search](/api-reference/endpoints/get-specific-search) when you need to reconcile search state — webhook delivery history is not exposed as a separate order-events API

## Testing Webhooks

### Using ngrok

```bash theme={null}
# Install ngrok
npm install -g ngrok

# Start your local server
node server.js

# Expose it via ngrok
ngrok http 3000

# Use the ngrok URL in your webhook config
# https://abc123.ngrok.io/webhooks/verification
```

### Using webhook.site

1. Visit [webhook.site](https://webhook.site)
2. Copy the unique URL provided
3. Use it in your webhook configuration for testing
4. View incoming webhooks in real-time

## Troubleshooting

### Webhooks Not Received

1. **Check endpoint URL**: Ensure your endpoint is accessible via HTTPS
2. **Verify signature**: Check that your secret matches the one in the webhook config
3. **Check firewall**: Ensure your server allows incoming connections from SNH AI
4. **Review logs**: Check API logs for webhook delivery errors

### Invalid Signature Errors

1. **Verify secret**: Ensure the secret in your code matches the one in webhook config
2. **Check payload format**: Ensure you're serializing the payload correctly
3. **Encoding**: Verify HMAC-SHA256 is using the correct encoding (hex)

### Webhooks Received Multiple Times

1. **Idempotency**: Implement idempotency checks using `X-Event-Id`
2. **Retry logic**: This is expected behavior - the API retries failed deliveries
3. **Duplicate detection**: Store processed event IDs to prevent duplicate processing

## Related Documentation

* [Webhook config Schema](/api-reference/schemas/webhook-config) - Webhook configuration schema
* [Create Order](/api-reference/endpoints/create-order) - Creating orders with webhook configuration
* [Error Handling](/guides/error-handling) - Error handling best practices
