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

Ask your account team to set an account-level webhook URL. Once configured, this becomes the default for every request you create. No per-request `webhookUrl` field needed.

### 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"
}
```

If neither is configured, no webhook is sent and you must poll `GET /v1/requests/{requestId}` for results.

## 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** is your account's **webhook secret** if you've set one up on the portal, otherwise it is your **production API key**. The same production-side secret is used for sandbox webhooks too. Your sandbox API key will fail verification. 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. On a **5xx response or connection failure** we automatically retry up to **3 more times** (4 attempts total) with exponential backoff (0.5s → 1s → 2s). A **4xx response is treated as terminal**: we don't retry, since re-posting the same payload is unlikely to succeed.

If all four attempts fail, 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; 4xx responses don't (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`).
