Skip to main content

Webhook Integration Guide

The Theary Background Check API supports webhooks to receive real-time notifications when verifications are completed, require action, or receive inbound responses. This guide covers webhook configuration, security, event types, and integration examples.

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.

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)
  • 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 - Sent when an inbound notification is received and processed (non-terminal outcomes)
For complete details on event types, payload structures, and when events are triggered, see Webhook Events.

Webhook Configuration

Webhooks can be configured at two levels:

Request-Level Configuration

Configure webhooks per verification request:
{
  "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"]
      }
    ]
  }
}

Tenant-Level Configuration

Configure webhooks at the tenant level (applies to all verifications unless overridden): Contact your Theary account manager to configure tenant-level webhook settings.

Endpoint Resolution

The API resolves webhook endpoints using the following priority:
  1. Search-specific endpoint: If configured for the search type (e.g., EMPLOYMENT), that endpoint is used
  2. Fallback endpoint: If no search-specific endpoint is configured, the fallback endpoint is used
  3. Tenant-level configuration: If no request-level configuration is provided, tenant-level settings are used
Notes:
  • When a search type is configured with multiple webhook targets, the API will deliver to all matching targets that allow the event.
  • 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. 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:
HeaderDescription
Content-TypeAlways application/json.
X-Event-TypeEvent type (verification.completed, verification.action_required, verification.notification).
X-Event-IdUnique delivery ID for this webhook (use for idempotency).
X-Search-TypeOptional; resolved search type such as EMPLOYMENT, EDUCATION, CRIMINAL, REFERENCE.
X-External-Search-IdOptional; echoes the external search identifier provided when the order was created.
X-Endpoint-SourceIndicates whether the request used a type-specific or fallback endpoint.
User-AgentAlways Theary-Webhook-Delivery/1.0.
AuthorizationPresent only when the webhook target specifies Basic Auth credentials (Authorization: Basic <base64(username:password)>).
X-Webhook-SignatureIncluded when a tenant-level or per-target secret is configured; value is sha256=<hex> for HMAC validation.
Notes:
  • If neither the tenant 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).

Security: Signature Verification

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

Node.js Example

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 })
})

Python Example

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.

Integration Examples

Express.js Webhook Handler

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)

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:
  • 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

Best Practices

1. Always Verify Signatures

Never process webhooks without verifying the HMAC signature. This ensures the webhook is from Theary 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. The API only sends to HTTPS URLs.

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 will retry based on your retryAttempts configuration
  2. After all retries are exhausted, the webhook is marked as failed
  3. You can check webhook delivery status via the API (if available)
  4. Consider implementing a dead-letter queue for failed webhooks

Testing Webhooks

Using ngrok

# 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
  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 Theary
  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