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

# Webhooks

> Receive request results when they reach a terminal state.

When a [request](/guides/concepts#request) reaches a terminal state, SuperDial sends an HTTP POST to your webhook endpoint with the result. Webhooks are the recommended way to consume request results.

## When webhooks fire

A webhook is sent once per request, when its [state](/guides/concepts#state) becomes one of:

| State | Meaning |
| - | - |
| `SUCCESS` | The request completed and produced a full result. Read the structured output from the request's `results` field via [`GET /v1/requests/{requestId}`](/guides/reading-requests#single-request). |
| `PARTIAL` | The request's primary call effort succeeded but a follow-up effort failed. The full GET returns `results` partially populated and `missingFields` listing what the follow-up didn't capture; `error` is `null`. |
| `FAILURE` | The request captured nothing usable. The full GET returns `results` empty and the [`error`](/guides/concepts#error-object) object describing the cause. |

No webhook is sent while a request is still `PROCESSING`.

## Configuration

You can set your webhook endpoint in two places:

### Account default

Set your account-level **Request webhook** URL in the SuperDial portal: go to **API**, find the **Webhooks** section, and fill in **Request webhook**. Once configured, this becomes the default for every request you create. No per-request `webhookUrl` field needed.

<Warning>
  The Requests API uses a **different** webhook URL from the legacy Calls API. In the same Webhooks section, your legacy URL appears separately as **Legacy webhook URL (/v1/calls)**. Setting one does nothing for the other, and there is no fallback between them. If you are moving from `POST /v1/calls` to `POST /v1/requests`, set the Request webhook URL before you cut traffic over, or your requests will complete and deliver nothing.
</Warning>

To confirm delivery is working, the same panel shows **Recent deliveries (7 days)**, filterable by Request ID. If you have submitted requests and that list is empty, the URL is not set correctly.

### Per-request override

Pass a `webhookUrl` field in the body of `POST /v1/requests` to direct a single request to a different URL. The per-request value always wins over the account default.

```json theme={null}
{
  "schemaId": "fWxzG4nqtpHsJxS5Lm3q",
  "inputs": { "payerName": "Sample Insurance Co", "memberId": "TEST123456789", "phoneNumber": "2125551234" },
  "webhookUrl": "https://your-app.example.com/webhooks/superdial/requests"
}
```

<Warning>
  If neither is configured, **no webhook is sent and no error is raised**. Delivery is a silent no-op: requests reach a terminal state normally, and nothing tells you the results went nowhere. Poll `GET /v1/requests/{requestId}` for results until a URL is set.
</Warning>

## Payload

```json theme={null}
{
  "requestId": "8bF7xK2mP9qR4sT6uV0w",
  "requestBatchId": "pH9kJ2lM4nB6vC8xZ7Qr",
  "state": "SUCCESS",
  "internalId": "claim_internal_456",
  "internalTag": "march-batch"
}
```

| Field | Type | Always present? | Notes |
| - | - | - | - |
| `requestId` | string | Yes | The request's ID. Use it to fetch the full result via `GET /v1/requests/{requestId}`. |
| `requestBatchId` | string | Yes | The batch this request belongs to. |
| `state` | string | Yes | One of `SUCCESS`, `PARTIAL`, or `FAILURE` (uppercase). |
| `internalId` | string | Only if supplied at create time | Echoed from your input. When you didn't supply one, the **key is absent from the JSON object entirely**: not `null`, not empty string. |
| `internalTag` | string | Only if supplied at create time | Echoed from your input. When you didn't supply one, the **key is absent from the JSON object entirely**: not `null`, not empty string. |

The webhook body is intentionally compact. To get the full result (`results`, `missingFields`, `modality`, `data_completeness`, and the [`error`](/guides/concepts#error-object) object on non-`SUCCESS`), call `GET /v1/requests/{requestId}` after receiving the webhook.

<Note>
  By the time you receive the webhook, the request is fully readable. For phone-backed requests, the call enrichment fields (`transcript`, `recordingDownloadUrl`, `callDuration`, `callSummary`) are available on the `GET /v1/requests/{requestId}` response.
</Note>

## Signature verification

Every webhook delivery includes an `X-Webhook-Signature` header. Verify it on every delivery. Without verification, anyone who learns your endpoint URL can forge events.

The signature is HMAC-SHA256 of the raw request body. The **signing secret** depends on which credentials created the request:

| Request created with | Signing secret |
| - | - |
| Production credentials | Your **webhook secret** if you've set one up on the portal, otherwise your **production API key**. |
| Sandbox credentials | Your **sandbox API key**. A webhook secret does not apply to sandbox deliveries. |

Compute the HMAC over the raw body bytes exactly as received: do not parse and re-serialize the JSON, since that changes the byte sequence and breaks the signature.

The header value is the **bare 64-character lowercase hex digest**, with no `sha256=` prefix or other framing. Example:

```http theme={null}
X-Webhook-Signature: 3f5b2a1c8d9e4f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
```

### Python

```python theme={null}
import hmac
import hashlib

def verify_webhook_signature(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
    expected = hmac.new(
        signing_secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)
```

### Node.js

```javascript theme={null}
import crypto from "node:crypto";

function verifyWebhookSignature(rawBody, signatureHeader, signingSecret) {
  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(rawBody)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(signatureHeader, "hex"),
  );
}
```

Both samples use a constant-time comparator (`hmac.compare_digest` / `crypto.timingSafeEqual`); use the equivalent in your language rather than `==` to avoid timing-attack risk.

The signature doesn't include a timestamp, so it can't be used to detect replays on its own. Use `requestId` as a dedup key (see [Idempotency](#idempotency) below).

## Delivery and retries

We send the webhook when the request reaches a terminal state. Delivery has two layers.

**Inline retries.** On a **5xx response or connection failure** we retry up to **3 more times** (4 attempts total) with exponential backoff (0.5s → 1s → 2s). Each attempt times out after 10 seconds.

**Later retries.** If all four inline attempts fail, we try the delivery again later. A request gets up to **3 delivery rounds** in total, within 7 days of reaching its terminal state.

How we treat each response:

| Response | Behavior |
| - | - |
| `400`, `404` | Delivery stops for good. No inline retry, no later round. Your endpoint read the payload and rejected it, so re-posting cannot succeed. |
| `401`, `403` | Retried across the remaining rounds. A rotated key or a fixed subscription can change the answer. |
| Other `4xx` | Retried across the remaining rounds. |
| `5xx`, connection failure | Inline retries, then the later rounds. |

Once all three rounds are used, the failure is recorded and no further attempts are made. Fall back to polling [`GET /v1/requests/{requestId}`](/guides/reading-requests#single-request) to recover.

For high-reliability ingestion:

* Ack with 2xx as soon as you've durably enqueued the event for processing.
* Track the `requestId`s you've submitted on your side. Any request whose terminal outcome you haven't received within the expected window should trigger a fallback poll.

## Idempotency

In rare cases the same `requestId` may arrive more than once. Always treat `requestId` as a dedup key:

```python theme={null}
if already_processed(payload["requestId"]):
    return 200  # idempotent ack
```

If you also passed `internalId` when creating the request, you have two layers of dedup keys. Pick whichever fits your system.

## Recommended response

Return **HTTP 200** as quickly as possible. Don't do synchronous work that could exceed our **10-second timeout**. Push processing to a background job and ack immediately.

5xx responses and connection failures trigger automatic retries. A `400` or `404` stops delivery permanently (see [Delivery and retries](#delivery-and-retries) above). If all retries are exhausted, fall back to polling `GET /v1/requests/{requestId}` to recover.

After acking, call `GET /v1/requests/{requestId}` to fetch the full payload (`results`, `missingFields`, `modality`, and the [`error`](/guides/concepts#error-object) object on `FAILURE`).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.