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

# Webhooks

> Endpoints for managing webhook endpoints and inspecting deliveries.

These endpoints register the URLs OptimalDial calls when something happens to one of your uploads. For the *receiving* side — payload shape, signature verification, retry behaviour — see the [Receiving webhooks](/guides/webhooks) guide.

## The WebhookEndpoint object

| Field                  | Type                      | Notes                                                                                                      |
| ---------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `id`                   | string (UUID)             | Stable identifier.                                                                                         |
| `url`                  | string                    | The HTTPS URL we deliver events to.                                                                        |
| `description`          | string \| null            | Free-form, max 500 characters. Useful for distinguishing endpoints in the UI.                              |
| `events`               | string\[]                 | The event types this endpoint receives. See [event types](#event-types).                                   |
| `is_active`            | boolean                   | If `false`, deliveries are skipped. Set to `false` automatically after [too many failures](#auto-disable). |
| `verified_at`          | string (ISO 8601) \| null | When we last successfully ping-verified the URL.                                                           |
| `last_success_at`      | string (ISO 8601) \| null | Most recent `2xx` delivery.                                                                                |
| `last_failure_at`      | string (ISO 8601) \| null | Most recent failed delivery attempt.                                                                       |
| `consecutive_failures` | integer                   | Reset to `0` on any successful delivery.                                                                   |
| `disabled_at`          | string (ISO 8601) \| null | Set when we auto-disable after consecutive failures.                                                       |
| `created_at`           | string (ISO 8601)         | UTC timestamp.                                                                                             |
| `updated_at`           | string (ISO 8601)         | UTC timestamp of the last change.                                                                          |

### Event types

| Event               | Fires when                                                                                    |
| ------------------- | --------------------------------------------------------------------------------------------- |
| `upload.created`    | An upload row is inserted (both API and web sources).                                         |
| `upload.completed`  | Processing finishes successfully. Payload includes signed download URLs.                      |
| `upload.failed`     | Processing fails. Payload includes `error_message`.                                           |
| `contact.completed` | A pending contact ([Contacts API](/api-reference/contacts)) has its `OptimalDial Status` set. |
| `contact.failed`    | A pending contact's row was missing or empty in the processed file.                           |

If you don't pass `events` when creating an endpoint, you get the default subset `["upload.completed", "upload.failed"]`. To receive `upload.created`, `contact.completed`, or `contact.failed` you must list them explicitly.

## Create a webhook endpoint

```http theme={null}
POST /api/v1/webhooks
```

Registers a URL to receive events. **The endpoint must respond `2xx` to a synchronous ping challenge** before we'll save it — this catches typos and unreachable URLs at registration time, not at first delivery.

### Request body

| Field         | Type               | Required | Notes                                                                                               |
| ------------- | ------------------ | -------- | --------------------------------------------------------------------------------------------------- |
| `url`         | string (HTTPS URL) | yes      | Must be `https://` and resolve to a public IP. See [URL safety rules](/guides/webhooks#url-safety). |
| `description` | string             | no       | Max 500 characters.                                                                                 |
| `events`      | string\[]          | no       | Defaults to `["upload.completed", "upload.failed"]`.                                                |

<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://hooks.example.com/optimaldial",
        "description": "production receiver",
        "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://hooks.example.com/optimaldial",
        description: "production receiver",
        events: ["upload.created", "upload.completed", "upload.failed"],
      }),
    });
    const { webhook, secret } = await res.json();
    // Save `secret` — it's never shown again.
    ```
  </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://hooks.example.com/optimaldial",
            "description": "production receiver",
            "events": ["upload.created", "upload.completed", "upload.failed"],
        },
    )
    data = resp.json()
    secret = data["secret"]  # save this — never shown again
    ```
  </Tab>
</Tabs>

### Response

```json theme={null}
{
  "webhook": { /* WebhookEndpoint */ },
  "secret": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
}
```

`secret` is a 64-character hex string (32 bytes of entropy). It is returned **only on creation** — store it next to the API key. We use it to compute the HMAC signature on every event sent to this endpoint, and your receiver uses it to verify those signatures.

### Errors

| Status | When                                                                                                                                                                                  |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | URL is not HTTPS, resolves to a private IP, or didn't respond `2xx` to the ping challenge. The body includes `error: "ping_failed"` plus the receiver's status code if one came back. |
| `401`  | Auth failure                                                                                                                                                                          |
| `429`  | Rate limit hit                                                                                                                                                                        |

A 400 looks like:

```json theme={null}
{
  "detail": {
    "error": "ping_failed",
    "message": "Endpoint did not respond 2xx to the ping challenge. Verify signature, then retry.",
    "status_code": 500,
    "underlying_error": null
  }
}
```

***

## List webhook endpoints

```http theme={null}
GET /api/v1/webhooks
```

Returns every endpoint registered for the API key's organization, newest first.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl https://api.optimaldial.com/api/v1/webhooks \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY"
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const res = await fetch("https://api.optimaldial.com/api/v1/webhooks", {
      headers: { Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}` },
    });
    const endpoints: WebhookEndpoint[] = await res.json();
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    endpoints = requests.get(
        "https://api.optimaldial.com/api/v1/webhooks",
        headers={"Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}"},
    ).json()
    ```
  </Tab>
</Tabs>

### Response

A JSON array of [`WebhookEndpoint`](#the-webhookendpoint-object) objects.

***

## Retrieve a webhook endpoint

```http theme={null}
GET /api/v1/webhooks/{webhook_id}
```

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl "https://api.optimaldial.com/api/v1/webhooks/$WEBHOOK_ID" \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY"
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const res = await fetch(
      `https://api.optimaldial.com/api/v1/webhooks/${webhookId}`,
      { headers: { Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}` } },
    );
    const endpoint: WebhookEndpoint = await res.json();
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    endpoint = requests.get(
        f"https://api.optimaldial.com/api/v1/webhooks/{webhook_id}",
        headers={"Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}"},
    ).json()
    ```
  </Tab>
</Tabs>

### Errors

| Status | When                                                      |
| ------ | --------------------------------------------------------- |
| `401`  | Auth failure                                              |
| `404`  | Endpoint doesn't exist or belongs to another organization |

***

## Update a webhook endpoint

```http theme={null}
PATCH /api/v1/webhooks/{webhook_id}
```

Partial update — send only the fields you want to change. The secret is **not** updatable; rotate by deleting and recreating the endpoint.

### Request body

| Field         | Type               | Notes                                                                                                                                                |
| ------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`         | string (HTTPS URL) | If changed, the new URL is ping-verified synchronously with the existing secret. The update fails if verification fails.                             |
| `description` | string             | Max 500 characters.                                                                                                                                  |
| `events`      | string\[]          | Replaces the current event list (no merge).                                                                                                          |
| `is_active`   | boolean            | Set to `true` to re-enable a manually disabled or auto-disabled endpoint. Re-enabling resets `consecutive_failures` to `0` and clears `disabled_at`. |

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl -X PATCH "https://api.optimaldial.com/api/v1/webhooks/$WEBHOOK_ID" \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"events": ["upload.completed"]}'
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const res = await fetch(
      `https://api.optimaldial.com/api/v1/webhooks/${webhookId}`,
      {
        method: "PATCH",
        headers: {
          Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ events: ["upload.completed"] }),
      },
    );
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    requests.patch(
        f"https://api.optimaldial.com/api/v1/webhooks/{webhook_id}",
        headers={
            "Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={"events": ["upload.completed"]},
    )
    ```
  </Tab>
</Tabs>

### Errors

| Status | When                                                                       |
| ------ | -------------------------------------------------------------------------- |
| `400`  | New URL failed ping verification, unknown event type, empty `events` array |
| `401`  | Auth failure                                                               |
| `404`  | Endpoint not found                                                         |
| `429`  | Rate limit hit                                                             |

***

## Delete a webhook endpoint

```http theme={null}
DELETE /api/v1/webhooks/{webhook_id}
```

Hard delete. There is no archive — once deleted, no further events are delivered and the endpoint disappears from list calls. Existing in-flight deliveries are marked exhausted.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl -X DELETE "https://api.optimaldial.com/api/v1/webhooks/$WEBHOOK_ID" \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY"
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    await fetch(`https://api.optimaldial.com/api/v1/webhooks/${webhookId}`, {
      method: "DELETE",
      headers: { Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}` },
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    requests.delete(
        f"https://api.optimaldial.com/api/v1/webhooks/{webhook_id}",
        headers={"Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}"},
    )
    ```
  </Tab>
</Tabs>

### Response

```json theme={null}
{ "status": "deleted" }
```

***

## List deliveries for a webhook endpoint

```http theme={null}
GET /api/v1/webhooks/{webhook_id}/deliveries
```

Returns recent delivery attempts for this endpoint, newest first. Useful for debugging — you can see exactly what we sent and what your endpoint replied with for each attempt.

### Query parameters

| Param   | Type            | Default | Notes      |
| ------- | --------------- | ------- | ---------- |
| `limit` | integer (1–100) | 20      | Page size. |

This endpoint is currently **limit-only** — there is no cursor parameter. To get older deliveries, lower the limit and rely on the natural ordering, or use the in-app developer panel.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl "https://api.optimaldial.com/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=50" \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY"
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const res = await fetch(
      `https://api.optimaldial.com/api/v1/webhooks/${webhookId}/deliveries?limit=50`,
      { headers: { Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}` } },
    );
    const deliveries: WebhookDelivery[] = await res.json();
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    deliveries = requests.get(
        f"https://api.optimaldial.com/api/v1/webhooks/{webhook_id}/deliveries",
        headers={"Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}"},
        params={"limit": 50},
    ).json()
    ```
  </Tab>
</Tabs>

### The WebhookDelivery object

| Field                  | Type                                                           | Notes                                                                                            |
| ---------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `id`                   | string (UUID)                                                  | Delivery identifier. Echoed in the `X-OptimalDial-Delivery-Id` header on the actual request.     |
| `webhook_endpoint_id`  | string (UUID)                                                  | The endpoint we tried to deliver to.                                                             |
| `event_id`             | string (UUID)                                                  | The event being delivered. Multiple endpoints subscribed to the same event share one `event_id`. |
| `event_type`           | string                                                         | E.g. `upload.completed`.                                                                         |
| `status`               | `"pending"` \| `"in_flight"` \| `"delivered"` \| `"exhausted"` | Current state.                                                                                   |
| `attempt_count`        | integer                                                        | How many delivery attempts we've made (1-indexed).                                               |
| `next_retry_at`        | string (ISO 8601)                                              | When the next attempt is scheduled, if any.                                                      |
| `last_attempt_at`      | string (ISO 8601) \| null                                      | Most recent attempt time.                                                                        |
| `last_response_status` | integer \| null                                                | Receiver's HTTP status on the most recent attempt.                                               |
| `last_error`           | string \| null                                                 | Truncated error message (e.g. timeout, DNS failure).                                             |
| `created_at`           | string (ISO 8601)                                              | When we enqueued this delivery.                                                                  |
| `delivered_at`         | string (ISO 8601) \| null                                      | Set when status flips to `delivered`.                                                            |

## Auto-disable

If an endpoint racks up **20 consecutive failed deliveries**, we set `is_active: false` and stamp `disabled_at`. No further events go out until you re-enable it.

To bring it back online: fix whatever was wrong (likely an outage or wrong URL), then `PATCH` the endpoint with `{"is_active": true}`. That call resets `consecutive_failures` to `0` and clears `disabled_at`. Future deliveries resume immediately, but anything queued during the outage that exhausted its retries is gone — re-fetch the upload state via `GET /api/v1/uploads` to catch up.
