> For the complete documentation index, see [llms.txt](https://docs.castmagic.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.castmagic.io/reference/webhooks.md).

# Webhooks

Receive real-time events when transcripts and outputs complete.

Webhooks push completion events to your server so you don't have to poll. Subscribe an endpoint to an event, and Castmagic will `POST` a signed JSON payload each time that event fires.

{% hint style="info" %}
**Tier:** `automation`. Base URL `https://app.castmagic.io`.
{% endhint %}

## Events

| Event                  | Fires when                        |
| ---------------------- | --------------------------------- |
| `transcript.completed` | A recording's transcript finishes |
| `output.completed`     | A generated output finishes       |

One event per webhook — to subscribe to several events, create one webhook per event.

## Create a webhook

<mark style="color:green;">`POST`</mark> `https://app.castmagic.io/v1/webhooks`

#### Request Body

| Name                                    | Type   | Description                                  |
| --------------------------------------- | ------ | -------------------------------------------- |
| url<mark style="color:red;">\*</mark>   | String | Public `https` URL to deliver to             |
| event<mark style="color:red;">\*</mark> | String | `transcript.completed` or `output.completed` |

{% hint style="warning" %}
The URL must be public `https`. Private and reserved addresses are rejected. The response includes the signing `secret` (`whsec_...`) **once**, on creation — store it, it cannot be retrieved again.
{% endhint %}

{% tabs %}
{% tab title="201: Created" %}

```json
{
    "webhook": {
        "id": "wh1...",
        "url": "https://example.com/hooks/castmagic",
        "event": "transcript.completed",
        "created_at": "2026-08-21T22:00:00Z",
        "secret": "whsec_..."
    }
}
```

{% endtab %}

{% tab title="400: Bad Request Non-public URL" %}

```json
{ "error": "url must resolve to a public address" }
```

{% endtab %}
{% endtabs %}

## List webhooks

<mark style="color:blue;">`GET`</mark> `https://app.castmagic.io/v1/webhooks`

{% tabs %}
{% tab title="200: OK" %}

```json
{
    "webhooks": [
        {
            "id": "wh1...",
            "url": "https://example.com/hooks/castmagic",
            "event": "transcript.completed",
            "created_at": "2026-08-21T22:00:00Z"
        }
    ],
    "count": 1
}
```

{% endtab %}
{% endtabs %}

## Delete a webhook

<mark style="color:red;">`DELETE`</mark> `https://app.castmagic.io/v1/webhooks/:id`

{% tabs %}
{% tab title="200: OK" %}

```json
{ "deleted": true, "id": "wh1..." }
```

{% endtab %}
{% endtabs %}

## Delivery payload

Events are delivered as a JSON `POST` to your URL:

```json
{
    "id": "b7d3...",
    "event": "transcript.completed",
    "created_at": "2026-08-21T22:00:00Z",
    "data": {
        "transcript_id": "t1...",
        "recording_id": "e1...",
        "space_id": "a1...",
        "duration_minutes": 42
    }
}
```

`output.completed` carries a different `data` shape:

```json
{
    "output_id": "o1...",
    "output_type": "dynamic",
    "recording_id": "e1...",
    "space_id": "a1...",
    "prompt_id": "p1...",
    "prompt_name": "LinkedIn post"
}
```

Deliveries fan out to every space member's subscribed webhooks.

## Deduplication & retries

The top-level `id` identifies the delivery and stays the same across retries — **deduplicate on it**. The retry attempt number is in the `x-castmagic-attempt` header.

Non-2xx responses are retried up to **5 times** with backoff.

## Verifying signatures

Every delivery carries an `x-castmagic-signature` header: `sha256=` followed by the hex HMAC-SHA256 of the **raw request body**, keyed with your webhook secret. Compute the same value and compare before trusting the payload.

```python
import hashlib
import hmac

def verify(secret: str, raw_body: bytes, signature_header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)
```

{% hint style="warning" %}
Sign against the **raw** request body bytes, exactly as received — re-serializing the parsed JSON will change the bytes and break verification.
{% endhint %}
