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

# Webhook Callbacks

> OpenAPI specification for the job.completed webhook callback

## Overview

This documents the `job.completed` webhook callback that TitanX sends to your configured endpoint once an entire job has reached its terminal state. It is a **job-level bookkeeping signal** — it confirms a batch is done but carries **no contact data**. Each contact's scored result was already delivered to you individually, in real time, via [`job.contact.scored`](/api-reference/v2/webhook-callbacks) as it was scored. A job is simply an organizational grouping of contacts; `job.completed` plays no role in data delivery.

<Info>
  **Related Documentation:**

  * [Webhooks Guide](/guides/webhooks/getting-started) - Implementation guide with code examples and event comparison
  * [job.contact.scored callback](/api-reference/v2/webhook-callbacks) - Per-contact webhook event
  * [Webhook Management API](/api-reference/v2#available-endpoints) - Endpoints to create and manage webhooks
</Info>

## When Does This Fire?

`job.completed` fires **once per job** when the job reaches a terminal state. This happens in one of two scenarios:

* **All contacts scored** — every contact in the job has been fully processed and scored (`jobStatus`: `"Finalized"`)
* **No contacts enriched** — all contacts were rejected during enrichment, so the job is finalized as "Not Enriched" (`jobStatus`: `"Not Enriched"`)

Timing depends on job size and current processing load.

<Note>
  `job.contact.scored` and `job.completed` are **not alternatives**. `job.contact.scored` is how you receive results — every contact fires its own event in real time as it is scored. `job.completed` delivers no contact data; use it for job-level bookkeeping, such as marking a batch complete in your UI once scoring is done.
</Note>

## POST job.completed

TitanX sends this POST request to your configured webhook URL when a job is fully finalized.

### Headers

<ParamField header="X-TitanX-Signature" type="string" required>
  HMAC-SHA256 signature (base64) of the request body. Always verify this before processing.

  Example: `jdoe+XYZ123abc/def456GHI789==`
</ParamField>

<ParamField header="Content-Type" type="string" required>
  Always `application/json`
</ParamField>

<ParamField header="User-Agent" type="string">
  Example: `TitanX-Webhooks/1.0`
</ParamField>

### Request Body

<ParamField body="payload" type="object" required>
  Job summary data.

  <Expandable title="Payload Properties">
    <ParamField body="jobId" type="string" required>
      Unique identifier of the finalized job (UUID).

      Example: `"a1b2c3d4-e5f6-7890-abcd-ef1234567890"`
    </ParamField>

    <ParamField body="jobType" type="enum">
      Type of the job. Use this to distinguish scoring vs. enrichment jobs and route the payload accordingly.

      Enum values: `scoring`, `enrichment`, `rescoring`, `score_enriched`, `enrich_rejected`, `enrich_final`, `titan_scoring`, `lightning_scoring`, `enrich_and_score`, `enrich_and_titan`, `enrich_score_rejected`, `enrich_titan_rejected`

      Example: `"scoring"`
    </ParamField>

    <ParamField body="jobName" type="string" required>
      Human-readable name of the job as set at creation time.

      Example: `"Q1 Outbound Campaign"`
    </ParamField>

    <ParamField body="jobStatus" type="string" required>
      The job's final status: `"Finalized"` when the job's contacts were scored, or `"Not Enriched"` when every contact was rejected during enrichment.
    </ParamField>

    <ParamField body="modifiedAt" type="string" required>
      ISO 8601 timestamp of when the job was finalized.

      Example: `"2024-01-15T10:35:00Z"`
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="eventType" type="string" required>
  Always `"job.completed"` for this callback.
</ParamField>

<ParamField body="timestamp" type="number" required>
  Unix timestamp in milliseconds when the event occurred.

  Example: `1705318200000`
</ParamField>

<ParamField body="apiVersion" type="string" required>
  API version. Currently `"v2"`.
</ParamField>

<ParamField body="id" type="string" required>
  Unique identifier for this webhook delivery (UUID). Use this for idempotency.

  Example: `"7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"`
</ParamField>

### Expected Response

Your endpoint should return a 2xx status code within 10 seconds.

| Status Code  | Behavior                                           |
| ------------ | -------------------------------------------------- |
| `200-299`    | Success — no retry                                 |
| `400-499`    | Client error — no retry (except 408, 429)          |
| `408`, `429` | Will retry                                         |
| `500-599`    | Server error — will retry with exponential backoff |

## Example Payload

```json theme={null}
{
  "payload": {
    "jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "jobType": "scoring",
    "jobName": "Q1 Outbound Campaign",
    "jobStatus": "Finalized",
    "modifiedAt": "2024-01-15T10:35:00Z"
  },
  "eventType": "job.completed",
  "timestamp": 1705318200000,
  "apiVersion": "v2",
  "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
}
```

## Handling the Event

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

  const { eventType, payload, id } = req.body;

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

  // 3. Process asynchronously
  if (eventType === 'job.completed') {
    const { jobId, jobName, jobStatus, modifiedAt } = payload;

    // Contacts already arrived individually via job.contact.scored.
    // Do job-level bookkeeping here (e.g. mark this batch complete in your UI).
    console.log(`Job "${jobName}" finished scoring at ${modifiedAt}`);
  }
});
```

<Tip>
  `job.completed` contains no contact data — you already have every contact from the per-contact `job.contact.scored` events, and the job is just an organizational grouping. If you need to reconcile or backfill a missed delivery, you can optionally call [GET /api/public/v2/jobs/\{jobId}/contacts](/api-reference/v2/get-job-contacts), but treat it as a fallback rather than the primary way to get results.
</Tip>

## Security

All webhook requests include `X-TitanX-Signature`. See [Verifying Webhook Signatures](/guides/webhooks/getting-started#security-verifying-webhook-signatures) for implementation examples in Node.js and Python.

## OpenAPI Definition

The complete OpenAPI 3.1 specification for this webhook callback is available in the [openapi.json](/api-reference/openapi.json) file under the `webhooks` section:

```yaml theme={null}
webhooks:
  job.completed:
    post:
      summary: job.completed webhook callback
      # Full specification in openapi.json
```

<Note>
  The webhook payload uses the `JobCompletedWebhookEventPayload` schema defined in the OpenAPI spec. This schema is generated directly from the application's TypeScript types to ensure the documentation always matches the actual implementation.
</Note>
