Skip to main content
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.
  • 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, tailscale funnel, or Cloudflare tunnel all work.

1. Create an API key

  1. Sign in to the app as an organization owner.
  2. Open DevelopersAPI keys.
  3. Click Create API key, name it (e.g. local-test), and copy the full key (od_live_…) into your terminal:
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:
# 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}"
A successful response looks like this (trimmed):
{
  "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. If you cancel before processing starts, the credits are refunded automatically.

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:
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"]
  }'
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:
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));
}
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