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

# Webhooks

## Overview

Webhooks allow you to receive real-time notifications when specific events occur in your TitanX account. Instead of polling our API for changes, webhooks push data to your server as events happen, enabling you to build responsive, event-driven integrations.

<Info>
  **Understanding Webhook Documentation:**

  * **[Webhook Management API](/api-reference/v2#available-endpoints)** - Endpoints you call to create, list, update, and delete webhook subscriptions
  * **[Webhook Callbacks](/api-reference/v2/webhook-callbacks)** - Documentation of the events TitanX sends to your webhook endpoints
  * **This guide** - Step-by-step instructions for implementing webhooks
</Info>

## How Webhooks Work

1. **Configure a webhook** - Set up an HTTPS endpoint URL and subscribe to specific event types
2. **Events occur** - When an event happens (e.g., a contact is scored), TitanX creates an event
3. **TitanX sends a POST request** - We send the event data to your webhook URL with an HMAC signature
4. **Your server responds** - Your endpoint processes the event and returns a 2xx status code
5. **Acknowledgment** - We consider the delivery successful when we receive a 2xx response

## Supported Events

### job.contact.scored

Fired when a contact completes the scoring process. This event provides the full contact object with all phone numbers annotated with their propensity scores.

**When it fires:**

* After a contact has been fully processed and scored
* For all of your contacts regardless of how they were submitted — via the scoring API **or** the TitanX App
* Only for contacts belonging to the authenticated user

### job.completed

Fired once per job, after the job reaches a terminal state — either all contacts were scored, or all contacts were rejected during enrichment (finalized as "Not Enriched"). This is a **job-level bookkeeping signal that the batch is done — it carries no contact data**. Your contact results are delivered separately and in real time via `job.contact.scored` (one event per contact); `job.completed` is just an optional checkpoint to know a whole batch has finished — for progress tracking or reconciliation — and is not required to receive results.

See the [job.completed callback reference](/api-reference/v2/job-completed-callback) for the full payload specification.

## Webhook Payload Structure

All webhook events follow a consistent structure:

```json theme={null}
{
  "payload": {
    "contact": {
      // Full Contact object for job.contact.scored
    },
    "job": {
      "id": "uuid-of-originating-job",
      "type": "scoring"
    }
  },
  "eventType": "job.contact.scored",
  "timestamp": 1705318200000,
  "apiVersion": "v2",
  "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
}
```

### Fields

* `payload` - The event data. For `job.contact.scored` events, contains a `contact` key with the full Contact object (see the [Webhook Callbacks reference](/api-reference/v2/webhook-callbacks) for the complete field list)
* `payload.contact` - The complete scored Contact object with all available fields
* `payload.job` - Context about the job that triggered this event
* `payload.job.id` - The ID of the originating job. Use this to correlate events back to a specific job. May be `null` for legacy events.
* `payload.job.type` - The type of job that produced this event. Use this to route handling logic, as different job types annotate different fields. May be `null` for legacy events.
* `eventType` - The type of event (such as "job.contact.scored")
* `timestamp` - Unix timestamp in milliseconds when the event occurred
* `apiVersion` - API version (currently "v2")
* `id` - Unique identifier for this webhook event (use for idempotency)

### Example: job.contact.scored Payload

The `payload.contact` contains the complete Contact object with all fields. Here's an example with commonly used fields:

```json theme={null}
{
  "payload": {
    "contact": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "userId": "abc123-def456-ghi789",
      "jobId": "job-uuid-here",
      "clientId": "client-uuid-here",
      "firstName": "John",
      "lastName": "Doe",
      "email": "john.doe@example.com",
      "phones": [
        {
          "number": "+15551234567",
          "status": "p1 - ivr",
          "type": "hq",
          "country": "US",
          "region": "US",
          "position": 1,
          "ivrPath": "#4021"
        },
        {
          "number": "+15559876543",
          "status": "p2",
          "type": "mobile",
          "country": "US",
          "region": "US",
          "position": 2,
          "ivrPath": null
        }
      ],
      "bestPhone": {
        "number": "+15551234567",
        "status": "p1 - ivr",
        "ivrPath": "#4021"
      },
      "title": "VP of Sales",
      "companyAccount": "Acme Corp",
      "website": "https://acme.com",
      "linkedInUrl": "https://linkedin.com/in/johndoe",
      "companyLinkedInUrl": "https://linkedin.com/company/acme",
      "city": "San Francisco",
      "stateProvince": "CA",
      "country": "USA",
      "companyCity": "San Francisco",
      "companyState": "CA",
      "companyCountry": "USA",
      "numberOfEmployees": "501-1000",
      "annualRevenue": "$50M-$100M",
      "industry": "Technology",
      "leadStatus": "P1",
      "createdAt": "2024-01-15T10:30:00Z",
      "updatedAt": "2024-01-15T10:35:00Z"
    },
    "job": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "type": "scoring"
    }
  },
  "eventType": "job.contact.scored",
  "timestamp": 1705318200000,
  "apiVersion": "v2",
  "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
}
```

<Note>
  The complete Contact object includes 60+ fields covering contact details, phone numbers, address information, company firmographics, and more. Phone data is provided in the canonical `phones[]` array — each entry includes `number`, `status`, `type`, `country`, `region`, `position`, and `ivrPath`, and the array also surfaces enrichment phones beyond the five-number cap. If you just want the one number to dial, read `bestPhone` — TitanX's pick of the highest-propensity, most reachable number from `phones[]` (`null` when none is suitable). The flat `phone`/`phone2`–`phone5` fields (and their `Status`/`Type`/`Country`/`Region` variants) remain for backward compatibility but are **deprecated**. See the [Webhook Callbacks reference](/api-reference/v2/webhook-callbacks) for the exhaustive field list.
</Note>

## Security: Verifying Webhook Signatures

Every webhook request includes an `X-TitanX-Signature` header containing an HMAC-SHA256 signature. You **must** verify this signature to ensure the request came from TitanX and hasn't been tampered with.

### Signature Format

The signature is computed as:

```
Base64(HMAC-SHA256(webhook_secret, request_body))
```

### Verification Example (Node.js)

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

const app = express();

// Important: Get raw body for signature verification
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

app.post('/webhooks/titanx', (req, res) => {
  const signature = req.headers['x-titanx-signature'];
  const webhookSecret = process.env.TITANX_WEBHOOK_SECRET;

  // Compute expected signature
  const hmac = crypto.createHmac('sha256', webhookSecret);
  const expectedSignature = hmac.update(req.rawBody).digest('base64');

  // Verify signature
  if (signature !== expectedSignature) {
    console.error('Invalid webhook signature');
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Signature verified - process the event
  const { eventType, payload, timestamp, id } = req.body;
  const { contact, job } = payload;

  console.log(`Received ${eventType} event:`, {
    id,
    timestamp,
    contactId: contact.id,
    jobId: job?.id,
    jobType: job?.type
  });

  if (eventType === 'job.contact.scored') {
    // Route based on job type if needed
    if (job?.type === 'enrichment') {
      // Handle enrichment-specific fields
    }

    // Process scored contact
    console.log('Contact scored:', {
      name: `${contact.firstName} ${contact.lastName}`,
      leadStatus: contact.leadStatus,
      company: contact.companyAccount
    });

    // Your business logic here
    // e.g., update CRM, send notification, trigger workflow
  }

  // Return 200 to acknowledge receipt
  res.status(200).json({ received: true });
});

app.listen(3000, () => {
  console.log('Webhook receiver running on port 3000');
});
```

### Verification Example (Python)

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

app = Flask(__name__)

WEBHOOK_SECRET = 'your-webhook-secret-here'

@app.route('/webhooks/titanx', methods=['POST'])
def handle_webhook():
    # Get signature from header
    signature = request.headers.get('X-TitanX-Signature')

    # Get raw request body
    raw_body = request.get_data(as_text=True)

    # Compute expected signature
    expected_signature = base64.b64encode(
        hmac.new(
            WEBHOOK_SECRET.encode('utf-8'),
            raw_body.encode('utf-8'),
            hashlib.sha256
        ).digest()
    ).decode('utf-8')

    # Verify signature
    if signature != expected_signature:
        return jsonify({'error': 'Invalid signature'}), 401

    # Parse event
    event = request.json
    event_type = event['eventType']
    payload = event['payload']
    job = payload.get('job', {})

    print(f"Received {event_type} event")

    if event_type == 'job.contact.scored':
        contact = payload['contact']

        # Route based on job type if needed
        job_type = job.get('type')
        if job_type == 'enrichment':
            pass  # Handle enrichment-specific fields

        # Process scored contact
        print(f"Contact scored: {contact['firstName']} {contact['lastName']}")
        # Your business logic here

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

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

## Setting Up Webhooks

### Step 1: Create Your Webhook Endpoint

Create an HTTPS endpoint that:

* Accepts POST requests
* Verifies the `X-TitanX-Signature` header
* Returns a 2xx status code within 10 seconds
* Handles events idempotently (using the event `id` field)

<Note>
  For detailed information about the webhook callback format and fields, see the [Webhook Callbacks](/api-reference/v2/webhook-callbacks) documentation.
</Note>

### Step 2: Create the Webhook via API

Use the [Create Webhook endpoint](/api-reference/v2/create-a-new-webhook-subscription) from the Webhook Management API:

```bash theme={null}
curl -X POST https://app.titanx.io/api/public/v2/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production Contact Webhook",
    "url": "https://api.example.com/webhooks/titanx",
    "events": ["job.contact.scored"],
    "status": "active"
  }'
```

**Important:** The response includes the webhook `secret` which is only shown once. Store it securely - you'll need it to verify webhook signatures.

<Warning>
  **Field naming: you send `events`, but responses return `triggers`.** When you create a webhook you supply the subscribed event types in an **`events`** array. The webhook objects the API returns — both from this create call and from [List All Webhooks](#list-all-webhooks) — expose that same subscribed list under a field named **`triggers`**, not `events`. They mean the same thing; only the field name differs between request and response. When parsing webhook responses, read **`triggers`**.

  (Separately, each delivered webhook callback identifies its own event in an `eventType` field — see [Webhook Callbacks](/api-reference/v2/webhook-callbacks). That value will be one of the event types you subscribed to.)
</Warning>

### Step 3: Test Your Webhook

After creating your webhook, score some contacts via the API and verify your endpoint receives the events.

## Rate Limiting

Webhooks are subject to rate limiting to prevent overwhelming your servers:

* **Default rate limit**: 5 requests per second per user
* **Configurable**: Rate limits can be adjusted via entitlements
* **Flow control**: Webhooks for the same user and event type share a rate limit key

If you need higher rate limits, contact support.

## Best Practices

### 1. Respond Quickly

Return a 2xx status code as soon as you receive the webhook. Process the event asynchronously if needed:

```javascript theme={null}
app.post('/webhooks/titanx', async (req, res) => {
  // Verify signature first
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Acknowledge immediately
  res.status(200).json({ received: true });

  // Process asynchronously
  processWebhookAsync(req.body).catch(err => {
    console.error('Error processing webhook:', err);
  });
});
```

### 2. Implement Idempotency

Use the event `id` to prevent processing duplicate events:

```javascript theme={null}
const processedEvents = new Set(); // In production, use a database

app.post('/webhooks/titanx', (req, res) => {
  const { id, eventType, payload } = req.body;

  // Check if already processed
  if (processedEvents.has(id)) {
    console.log(`Event ${id} already processed, skipping`);
    return res.status(200).json({ received: true });
  }

  // Process event
  processEvent(eventType, payload);

  // Mark as processed
  processedEvents.add(id);

  res.status(200).json({ received: true });
});
```

### 3. Handle Errors Gracefully

If your endpoint returns a non-2xx status code, we'll consider the delivery failed. Implement retry logic in your application for transient failures.

### 4. Monitor Webhook Health

* Log all webhook events for debugging
* Monitor success/failure rates
* Set up alerts for consistent failures
* Use the webhook `status` endpoint to pause webhooks during maintenance

### 5. Use HTTPS

Webhook URLs must use HTTPS. This ensures:

* Data is encrypted in transit
* Your endpoint is authenticated
* Man-in-the-middle attacks are prevented

## Managing Webhooks

### List All Webhooks

```bash theme={null}
curl https://app.titanx.io/api/public/v2/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Update Webhook Status

Pause a webhook without deleting it:

```bash theme={null}
curl -X POST https://app.titanx.io/api/public/v2/webhooks/{id}/inactive \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Reactivate:

```bash theme={null}
curl -X POST https://app.titanx.io/api/public/v2/webhooks/{id}/active \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Delete a Webhook

```bash theme={null}
curl -X DELETE https://app.titanx.io/api/public/v2/webhooks/{id} \
  -H "Authorization: Bearer YOUR_API_KEY"
```

## Understanding Contact Scores

When you receive a `job.contact.scored` event, the contact will include phone status annotations:

### Phone Statuses

<ResponseField name="phoneStatus" type="enum">
  The status of the phone number

  <Info>IVR stands for Interactive Voice Response, which you may know as a dial tree</Info>

  <Expandable title="Phone Status Values">
    <ResponseField name="p1" type="string">
      Contact is reachable via phone
    </ResponseField>

    <ResponseField name="p1 - h" type="string">
      Contact is reachable via phone, but the number is a corporate number that goes through an operator or gatekeeper.
    </ResponseField>

    <ResponseField name="p1 - ivr" type="string">
      Contact is reachable via phone, but the number is an IVR
    </ResponseField>

    <ResponseField name="p1 - dnc" type="string">
      Contact is reachable via phone, but the number is a DNC (Do Not Call)
    </ResponseField>

    <ResponseField name="p2" type="string">
      Contact is reachable via phone, but the number is not a mobile number
    </ResponseField>

    <ResponseField name="p2 - h" type="string">
      Contact is reachable via phone, but the number is a corporate number that goes through an operator or gatekeeper.
    </ResponseField>

    <ResponseField name="p2 - ivr" type="string">
      Contact is reachable via phone, but the number is an IVR
    </ResponseField>

    <ResponseField name="p3" type="string">
      Contact is not reachable via phone
    </ResponseField>

    <ResponseField name="p3 - h" type="string">
      Contact is not reachable via phone, and the number is a corporate number that goes through an operator or gatekeeper.
    </ResponseField>

    <ResponseField name="p3 - ivr" type="string">
      Contact is not reachable via phone, and the number is an IVR
    </ResponseField>

    <ResponseField name="na" type="string">
      Contact is not available via phone
    </ResponseField>

    <ResponseField name="na - h" type="string">
      Contact is not available via phone, and the number is a landline
    </ResponseField>

    <ResponseField name="na - ivr" type="string">
      Contact is not available via phone, and the number is an IVR
    </ResponseField>

    <ResponseField name="na - dnc" type="string">
      Contact is not available via phone, and the number is a DNC (Do Not Call)
    </ResponseField>
  </Expandable>
</ResponseField>

### Lead Status

Each contact has an overall `leadStatus` field that represents the highest scoring phone number:

<ResponseField name="leadStatus" type="enum">
  The overall status of the contact based on the highest scoring phone status.

  <Expandable title="Lead Status Values">
    <ResponseField name="P1" type="string">
      Contact is reachable via phone
    </ResponseField>

    <ResponseField name="P2" type="string">
      Contact is reachable via phone, but the number is not a mobile number
    </ResponseField>

    <ResponseField name="P3" type="string">
      Contact is not reachable via phone
    </ResponseField>

    <ResponseField name="Needs Attention" type="string">
      Bad data or unable to navigate path
    </ResponseField>

    <ResponseField name="No Numbers Found" type="string">
      No phone numbers were found for the contact
    </ResponseField>

    <ResponseField name="Uncontacted" type="string">
      The contact has not yet been scored
    </ResponseField>
  </Expandable>
</ResponseField>

## Troubleshooting

### Webhook Not Firing

* Verify the webhook status is "active"
* Check that contacts belong to the authenticated user (the event fires for both API- and App-submitted contacts, so the source is not the issue)
* Verify your endpoint is accessible from the internet

### Signature Verification Failing

* Ensure you're using the raw request body (not parsed JSON)
* Verify you're using the correct webhook secret
* Check that you're computing Base64-encoded HMAC-SHA256
* Verify the signature header name is exactly `X-TitanX-Signature`

### Timeouts

* Return a 2xx response within 10 seconds
* Process events asynchronously if they take longer
* Consider using a message queue for complex processing

## Need Help?

If you have questions or need assistance with webhooks:

* Email: [support@titanx.io](mailto:support@titanx.io)
* Check our [API Reference](/api-reference/v2/create-a-new-webhook-subscription) for detailed endpoint documentation
