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

# Contacts

> Submit one phone number for OptimalDial Status classification. Designed for per-row enrichment integrations.

The Contacts API accepts **one phone number per request** and returns an OptimalDial Status classification (e.g. `"Likely Answer"`, `"Likely Voicemail"`) once processing finishes. Use it when your tooling enriches one row at a time and the bulk Uploads API doesn't fit.

It's available to every account with an API key — no special access needed. Each contact costs **1 credit** at the default `standard` tier, or 1.5x at `max` — see [Validation tiers](#validation-tiers).

## Lifecycle

1. `POST /api/v2/contacts` — submit one phone number. We charge credits per the [validation tier](#validation-tiers) (**1 credit** at the default `standard`) and return immediately with a `contact_id` in `queued` state.
2. Processing happens asynchronously. Typical turnaround is within 24-48 hours.
3. When processing finishes, the contact transitions to `completed` (with `optimaldial_status` set) or `failed` (with `error_message`).
4. You learn the result via either:
   * The `callback_url` you provided on creation (a signed POST per contact — recommended for integrations that can listen for webhooks), **or**
   * Your registered org-level webhook endpoints (`contact.completed` / `contact.failed`), **or**
   * Polling `GET /api/v2/contacts/{contact_id}`.

## The Contact object

| Field                       | Type                    | Notes                                                                                                                                                                                                       |
| --------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                        | string (UUID)           | Stable identifier.                                                                                                                                                                                          |
| `organization_id`           | string (UUID)           | Org the contact belongs to.                                                                                                                                                                                 |
| `phone`                     | string                  | E.164-normalized phone (US/CA only).                                                                                                                                                                        |
| `properties`                | object                  | Whatever extra fields you sent on creation, echoed back in result payloads.                                                                                                                                 |
| `status`                    | enum                    | One of: `queued`, `processing`, `completed`, `failed`, `discarded`.                                                                                                                                         |
| `optimaldial_status`        | string \| null          | On v2, the nine-tier value — e.g. `"1 - Likely Right-Party Answer"`. On v1, the Answer Intent value — e.g. `"Likely Answer"`. Populated when `status == "completed"`.                                       |
| `optimaldial_answer_intent` | string \| null          | **v2 only.** Whether the number is likely to pick up, on its own.                                                                                                                                           |
| `optimaldial_identity`      | string \| null          | **v2 only.** `Very High` / `High` / `Medium` / `Low` / `Unknown`. Null when the batch had no name columns mapped.                                                                                           |
| `error_message`             | string \| null          | Populated when `status == "failed"`.                                                                                                                                                                        |
| `message`                   | string \| null          | Human-readable note about the current state. Populated while the contact is still `queued`/`processing` (e.g. "Contact has not been processed yet…"); `null` once it reaches a terminal state.              |
| `validation_tier`           | `"standard"` \| `"max"` | Tier this contact was submitted at, locked at creation. Defaults to `standard`.                                                                                                                             |
| `credits_charged`           | integer                 | Credits deducted for this contact — always `1` at `standard`. At `max` the per-contact amount alternates; see [Validation tiers](#validation-tiers). Refunded in full if the contact is discarded or fails. |
| `created_at`                | string (ISO 8601)       | UTC timestamp.                                                                                                                                                                                              |
| `updated_at`                | string (ISO 8601)       | UTC timestamp of the last status change.                                                                                                                                                                    |

### Statuses

| Status       | Meaning                                                                |
| ------------ | ---------------------------------------------------------------------- |
| `queued`     | Submitted, awaiting processing.                                        |
| `processing` | Processing in progress.                                                |
| `completed`  | Result is on `optimaldial_status`.                                     |
| `failed`     | Processing finished but did not produce a result; see `error_message`. |
| `discarded`  | Discarded by OptimalDial; credit refunded. Rare.                       |

***

## v2 — the current version

<Note>
  The identity fields below come from **OptimalDial Identity, currently in public
  beta**. The response shape is stable; we're still tuning the thresholds. Feedback
  welcome at [support@optimaldial.com](mailto:support@optimaldial.com).
</Note>

`/api/v2/contacts` mirrors every v1 route — create, list, and retrieve — and
additionally reports the nine-tier `OptimalDial_Status` and the
[Identity](/api-reference/uploads#optimaldial-identity) band.

|          | v1                          | v2                          |
| -------- | --------------------------- | --------------------------- |
| Create   | `POST /api/v1/contacts`     | `POST /api/v2/contacts`     |
| List     | `GET /api/v1/contacts`      | `GET /api/v2/contacts`      |
| Retrieve | `GET /api/v1/contacts/{id}` | `GET /api/v2/contacts/{id}` |

Requests are identical — same fields, same credits, same idempotency semantics,
same callbacks. Only the response differs:

| Field                       | v1                | v2                                           |
| --------------------------- | ----------------- | -------------------------------------------- |
| `optimaldial_status`        | `"Likely Answer"` | `"1 - Likely Right-Party Answer"` (the tier) |
| `optimaldial_answer_intent` | —                 | `"Likely Answer"`                            |
| `optimaldial_identity`      | —                 | `"Very High"`                                |

```json theme={null}
{
  "id": "8a1f…",
  "status": "completed",
  "optimaldial_status": "1 - Likely Right-Party Answer",
  "optimaldial_answer_intent": "Likely Answer",
  "optimaldial_identity": "Very High"
}
```

<Note>
  **v1 is legacy — supported indefinitely, with no removal planned.** It simply
  doesn't include identity: `optimaldial_status` there keeps returning the Answer
  Intent value, so existing filters keep working exactly as before.

  **There is no migration.** Both versions operate on the same contacts, so one
  created on v1 can be read on v2 and vice versa. Point your calls at `/api/v2/`
  whenever you're ready — or don't, and stay on v1.

  The two new fields are `null` on contacts whose batch was processed without
  identity.
</Note>

The `contact.completed` webhook payload gains `optimaldial_tier` and
`optimaldial_identity` **alongside** the existing `optimaldial_status`, which is
unchanged. Existing consumers need no action.

***

## Validation tiers

Each contact runs at one of two tiers. Pass `validation_tier` on [Create a contact](#create-a-contact); it defaults to `standard`.

| Tier       | Credits per number | Notes                                                                 |
| ---------- | ------------------ | --------------------------------------------------------------------- |
| `standard` | 1                  | Optimized for speed and volume.                                       |
| `max`      | 1.5                | More thorough; identifies more likely answerers over a longer window. |

The value is case-insensitive, and anything other than `standard` or `max` returns `400`.

`max` is available on this endpoint **at any volume, including a single contact**. The 100-number minimum documented on the bulk [Uploads API](/api-reference/uploads#create-an-upload) applies only there — if your pipeline produces a handful of numbers at a time, submit them here per-row rather than accumulating to reach that floor.

```bash theme={null}
curl -X POST https://api.optimaldial.com/api/v2/contacts \
  -H "Authorization: Bearer $OPTIMALDIAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+15551234567", "validation_tier": "max" }'
```

### How `max` is billed per contact

Credits are whole-number ledger entries, so `max` can't charge a fraction on a single-contact request. It bills **marginally** instead: successive `max` contacts are charged `2, 1, 2, 1, …` credits.

The alternation runs across your current queued batch — the `max` contacts you've submitted that haven't moved to `processing` yet. Whatever that batch ends up holding, its total is exactly `ceil(N × 1.5)`: the same price as submitting those N numbers as one `max` upload.

| `max` contacts in the batch | Total credits |
| --------------------------- | ------------- |
| 1                           | 2             |
| 2                           | 3             |
| 5                           | 8             |
| 40                          | 60            |

So there's no cost penalty for submitting one at a time instead of batching.

Because the charge alternates, the subtotal for any one run depends on where it lands in the batch: five `max` contacts cost 8 credits when they open a fresh batch, or 7 when they land mid-alternation. A run of n is never charged more than `ceil(n × 1.5)`. Read `credits_charged` on each response for the exact amount deducted rather than assuming a fixed per-contact cost.

***

## Create a contact

```http theme={null}
POST /api/v2/contacts
```

Submits one phone for processing. Returns `201` with the new contact in `queued` status.

<Info>
  `POST /api/v1/contacts` accepts an identical request and is still supported — it
  just returns the [legacy response shape](#v2--the-current-version), without the
  identity fields.
</Info>

### Request body

| Field             | Type                    | Required | Notes                                                                                                                                                                                                             |
| ----------------- | ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `phone`           | string                  | yes      | Any common format. Normalized to E.164 server-side. Must be US or Canadian.                                                                                                                                       |
| `validation_tier` | `"standard"` \| `"max"` | no       | Validation tier (defaults to `standard`). Case-insensitive. `max` is more thorough and costs 1.5 credits/number, with no minimum submission size — see [Validation tiers](#validation-tiers).                     |
| `properties`      | object                  | no       | Arbitrary extra fields echoed back in result payloads. Use these to correlate the result with your own row IDs.                                                                                                   |
| `callback_url`    | string (HTTPS)          | no       | Optional per-contact webhook. We POST to it when the contact's result is ready. Signed with `callback_secret` if provided. Same retry semantics as registered org webhooks (6 attempts with exponential backoff). |
| `callback_secret` | string                  | no       | HMAC-SHA256 secret used to sign the `callback_url` payload.                                                                                                                                                       |
| `idempotency_key` | string                  | no       | Optional. Re-posting with the same key returns the existing contact without an extra credit charge. Scoped per `(organization, api_key)`.                                                                         |

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl -X POST https://api.optimaldial.com/api/v2/contacts \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "phone": "+15551234567",
        "properties": { "row_id": "abc123", "first_name": "Alex" },
        "callback_url": "https://hooks.example.com/optimaldial-contact",
        "callback_secret": "shared-secret-for-hmac",
        "idempotency_key": "row-abc123"
      }'
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const res = await fetch("https://api.optimaldial.com/api/v2/contacts", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        phone: "+15551234567",
        properties: { row_id: "abc123", first_name: "Alex" },
        callback_url: "https://hooks.example.com/optimaldial-contact",
        callback_secret: process.env.CALLBACK_SECRET,
        idempotency_key: "row-abc123",
      }),
    });
    const contact = await res.json();
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    resp = requests.post(
        "https://api.optimaldial.com/api/v2/contacts",
        headers={
            "Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "phone": "+15551234567",
            "properties": {"row_id": "abc123", "first_name": "Alex"},
            "callback_url": "https://hooks.example.com/optimaldial-contact",
            "callback_secret": os.environ["CALLBACK_SECRET"],
            "idempotency_key": "row-abc123",
        },
    )
    contact = resp.json()
    ```
  </Tab>
</Tabs>

### Response

`201 Created`:

```json theme={null}
{
  "id": "9b1f4f5e-0a8a-4c4c-8d2e-d9d0f9b4a1f1",
  "organization_id": "...",
  "phone": "+15551234567",
  "properties": { "row_id": "abc123", "first_name": "Alex" },
  "status": "queued",
  "optimaldial_status": null,
  "error_message": null,
  "message": "Contact has not been processed yet. Poll this endpoint or wait for the contact.completed webhook.",
  "validation_tier": "standard",
  "credits_charged": 1,
  "created_at": "2026-05-04T18:01:23+00:00",
  "updated_at": "2026-05-04T18:01:23+00:00"
}
```

### Errors

| Status | When                                         |
| ------ | -------------------------------------------- |
| `400`  | `validation_tier` is not `standard` or `max` |
| `401`  | Missing, invalid, or revoked API key         |
| `402`  | Organization out of credits                  |
| `422`  | Phone is unparseable or not US/CA            |
| `429`  | Rate limit hit                               |

***

## Retrieve a contact

```http theme={null}
GET /api/v2/contacts/{contact_id}
```

Returns the latest snapshot. Use as a fallback if you missed a webhook delivery, or to poll until `status == "completed"`.

```bash theme={null}
curl "https://api.optimaldial.com/api/v2/contacts/$CONTACT_ID" \
  -H "Authorization: Bearer $OPTIMALDIAL_API_KEY"
```

***

## List contacts

```http theme={null}
GET /api/v2/contacts
```

Cursor-paginated, newest first. Optional `status` filter.

| Param    | Type            | Default | Notes                                               |
| -------- | --------------- | ------- | --------------------------------------------------- |
| `status` | enum            | —       | Filter by one of the [statuses](#statuses).         |
| `limit`  | integer (1–100) | 50      | Page size.                                          |
| `cursor` | string          | —       | Pass back `next_cursor` from the previous response. |

```bash theme={null}
curl "https://api.optimaldial.com/api/v2/contacts?status=completed&limit=100" \
  -H "Authorization: Bearer $OPTIMALDIAL_API_KEY"
```

```json theme={null}
{
  "data": [ /* Contact, Contact, … */ ],
  "next_cursor": "2026-05-04T17:55:00+00:00"
}
```

***

## Result delivery

When a contact reaches `completed` or `failed`, OptimalDial fires up to two notifications:

1. **Per-contact callback** — if you provided `callback_url` on creation, we POST a signed JSON body to it. Same signature scheme and retry semantics as [org-level webhooks](/guides/webhooks#verifying-signatures): HMAC-SHA256 of `f"{ts}.{body}"` using your `callback_secret`, 6 attempts with exponential backoff. Headers include `X-OptimalDial-Signature` and `X-OptimalDial-Timestamp`.
2. **Org-level webhooks** — your registered [webhook endpoints](/api-reference/webhooks) receive `contact.completed` / `contact.failed` events for every contact that finishes. Useful for fan-out to internal systems independent of the per-contact callback.

Both delivery paths emit the same payload shape, wrapping the [Contact object](#the-contact-object):

```json theme={null}
{
  "id": "evt_contact_...",
  "type": "contact.completed",
  "created_at": "2026-05-04T19:30:01+00:00",
  "data": {
    "contact": {
      "id": "9b1f4f5e-0a8a-4c4c-8d2e-d9d0f9b4a1f1",
      "phone": "+15551234567",
      "properties": { "row_id": "abc123", "first_name": "Alex" },
      "status": "completed",
      "optimaldial_status": "Likely Answer",
      "validation_tier": "max",
      "credits_charged": 2,
      "created_at": "2026-05-04T18:01:23+00:00"
    }
  }
}
```

The `properties` object on the result echoes back exactly what you sent on creation — use it to correlate the result with your own row IDs without storing the OptimalDial `contact_id`.
