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

# Getting started

> Make your first OptimalDial API call in five minutes.

This guide walks you through creating an API key, submitting your first list, and receiving the webhook that fires when processing finishes. Allow about five minutes.

## Prerequisites

* An OptimalDial account at [app.optimaldial.com](https://app.optimaldial.com).
* The **owner** role on at least one organization with an **active subscription**. (Spam-monitoring-only plans don't include API access — you need a list-processing plan.)
* A terminal with `curl`, or Node.js 18+ / Python 3.9+.
* A publicly reachable HTTPS URL for receiving webhooks. For local development, [ngrok](https://ngrok.com), [tailscale funnel](https://tailscale.com/kb/1223/funnel), or [Cloudflare tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) all work.

## 1. Create an API key

1. Sign in to the app as an organization owner.
2. Open *Developers* → *API keys*.
3. Click **Create API key**, name it (e.g. `local-test`), and copy the full key (`od_live_…`) into your terminal:

```bash theme={null}
export OPTIMALDIAL_API_KEY="od_live_FxXkV6bA2YqpW3LhR9zJMTnGoQ8sK4dC"
```

The key is only shown once — store it in your secrets manager before closing the modal.

## 2. Submit your first list

Uploads must contain **at least 100 valid US or Canadian phone numbers**. Numbers can be in any common shape (`+15551234567`, `(555) 123-4567`, `5551234567`); we normalize them to E.164 server-side.

The simplest call is the JSON `phone_numbers` mode — no CSV required:

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    # Build a tiny array of 100 numbers (replace these with real ones)
    NUMBERS=$(python3 -c 'import json; print(json.dumps([f"+1555{n:07d}" for n in range(100)]))')

    curl -X POST https://api.optimaldial.com/api/v1/uploads \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d "{\"filename\":\"first-list.csv\",\"phone_numbers\":$NUMBERS}"
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const numbers = Array.from({ length: 100 }, (_, i) =>
      `+1555${String(i).padStart(7, "0")}`,
    );

    const res = await fetch("https://api.optimaldial.com/api/v1/uploads", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        filename: "first-list.csv",
        phone_numbers: numbers,
      }),
    });

    const upload = await res.json();
    console.log("Created upload", upload.id, "status:", upload.status);
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import os, requests

    numbers = [f"+1555{n:07d}" for n in range(100)]

    resp = requests.post(
        "https://api.optimaldial.com/api/v1/uploads",
        headers={
            "Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={"filename": "first-list.csv", "phone_numbers": numbers},
    )
    resp.raise_for_status()
    upload = resp.json()
    print("Created upload", upload["id"], "status:", upload["status"])
    ```
  </Tab>
</Tabs>

A successful response looks like this (trimmed):

```json theme={null}
{
  "id": "f3c9b8a7-1234-4abc-9def-0123456789ab",
  "status": "ready_for_processing",
  "source": "api",
  "valid_row_count": 100,
  "invalid_row_count": 0,
  "credits_required": 100,
  "credits_charged": 100,
  "created_at": "2026-04-24T12:34:56.789+00:00"
}
```

`credits_charged` reflects the deduction from your organization's balance — one credit per valid number. Submitting is final: uploads can't be cancelled, so check `valid_row_count` in the response before you queue the next batch.

## 3. Register a webhook so you get notified when processing finishes

Polling `GET /api/v1/uploads/{id}` works, but webhooks are cheaper and faster. Stand up an HTTPS endpoint that returns `2xx` to any `POST` (any framework will do), then register it:

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl -X POST https://api.optimaldial.com/api/v1/webhooks \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://example.com/optimaldial-webhook",
        "description": "local dev",
        "events": ["upload.created", "upload.completed", "upload.failed"]
      }'
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const res = await fetch("https://api.optimaldial.com/api/v1/webhooks", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        url: "https://example.com/optimaldial-webhook",
        description: "local dev",
        events: ["upload.created", "upload.completed", "upload.failed"],
      }),
    });
    const { webhook, secret } = await res.json();
    console.log("Endpoint", webhook.id, "secret:", secret);
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    resp = requests.post(
        "https://api.optimaldial.com/api/v1/webhooks",
        headers={
            "Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "url": "https://example.com/optimaldial-webhook",
            "description": "local dev",
            "events": ["upload.created", "upload.completed", "upload.failed"],
        },
    )
    resp.raise_for_status()
    data = resp.json()
    print("Endpoint", data["webhook"]["id"], "secret:", data["secret"])
    ```
  </Tab>
</Tabs>

Two things happen synchronously when you call `POST /api/v1/webhooks`:

1. We send a **`webhook.ping`** event to the URL you provided.
2. If your endpoint responds `2xx` within 5 seconds, the webhook is saved and marked active. Otherwise the call returns `400` and nothing is persisted — you can edit the URL and retry.

The response includes `secret` — a 64-character hex string used to verify HMAC signatures on every event we send. **It is shown exactly once.** Save it next to the API key.

## 4. Verify the next event you receive

Every webhook delivery carries an `X-OptimalDial-Signature` header:

```
X-OptimalDial-Signature: t=1714000000,v1=2c9a2e91f0d0a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8
```

Verify it on every request before processing the body. The signature scheme is HMAC-SHA256 over `f"{timestamp}.{body}"` — Stripe-compatible, and the verifier is short:

<Tabs>
  <Tab title="Node.js">
    ```ts theme={null}
    import crypto from "node:crypto";

    function verifyOptimalDial(rawBody: string, header: string, secret: string) {
      const parts = Object.fromEntries(
        header.split(",").map(kv => kv.split("=", 2)),
      );
      const ts = Number(parts.t);
      if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5-min replay window

      const expected = crypto
        .createHmac("sha256", secret)
        .update(`${ts}.${rawBody}`)
        .digest("hex");
      return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import hashlib, hmac, time

    def verify_optimaldial(raw_body: str, header: str, secret: str) -> bool:
        try:
            parts = dict(kv.split("=", 1) for kv in header.split(","))
            ts = int(parts["t"])
            provided = parts["v1"]
        except Exception:
            return False
        if abs(int(time.time()) - ts) > 300:  # 5-min replay window
            return False
        expected = hmac.new(
            secret.encode(),
            f"{ts}.{raw_body}".encode(),
            hashlib.sha256,
        ).hexdigest()
        return hmac.compare_digest(expected, provided)
    ```
  </Tab>
</Tabs>

You'll see a `webhook.ping` arrive immediately when you register, then `upload.created` as soon as your call in step 2 returns. `upload.completed` arrives when processing finishes (typically minutes for small lists), with signed download URLs in the payload.

## What's next

* The full list of endpoints with every option: [Uploads reference](/api-reference/uploads), [Webhooks reference](/api-reference/webhooks).
* A deeper dive on retries, headers, idempotency, and best practices: [Receiving webhooks](/guides/webhooks).
* Production-readiness checklist for handling errors and rate limits: [Errors and rate limits](/guides/errors-and-rate-limits).
