> For the full page index, fetch https://echonova.ai/llms.txt

# Webhook events

The four events EchoNova sends, the headers on every delivery, and the exact JSON body of each event.

This page lists every event a webhook can subscribe to and the request your
endpoint receives for each. Use it to write the handler; use
[Verify a webhook signature](/docs/developers/webhooks-verify-signature) to
check that a request came from EchoNova before you act on it.

## Event types

| Event                | Portal label           | Sent when                                                                                                              |
| -------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `job.completed`      | **Job completed**      | An asynchronous Text to Speech or Speech to Text job started with an API key finishes with a result.                   |
| `job.failed`         | **Job failed**         | The same kind of job fails. The credits it reserved are refunded before the event is sent.                             |
| `spending.warning`   | **Spending warning**   | A metered call takes an API key that has a **Credit Limit** to 80% or more of that limit. Sent once per billing cycle. |
| `spending.exhausted` | **Spending exhausted** | A metered call leaves an API key that has a **Credit Limit** at or above that limit. Sent on every such call.          |

Synchronous calls never produce `job.completed` or `job.failed`. Jobs started
from the portal are not announced either. Spending events are produced by
both synchronous and asynchronous calls, and only for keys with a
**Credit Limit**.

## The request

Every delivery is one `POST` to your **Endpoint URL** with these headers:

| Header                | Value                                                                                               |
| --------------------- | --------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                                                  |
| `X-Webhook-Event`     | The event name, for example `job.completed`.                                                        |
| `X-Webhook-Timestamp` | Unix time in seconds when this attempt was signed, as a decimal string.                             |
| `X-Webhook-Signature` | HMAC-SHA256 of `<timestamp>.<body>` keyed with your signing secret, as 64 lowercase hex characters. |

The body is the event payload and nothing else. There is no envelope: no `id`,
no `type` and no `createdAt` field inside the JSON. The event name travels in
`X-Webhook-Event` only, so read that header before you parse the body.

A retry sends the same body with a new timestamp and a new signature. See
[Delivery, retries and failures](/docs/developers/webhooks-retries).

## job.completed

Sent once the job's result is ready. The result travels in the event, so
you do not need to call the API back for it, except for the speaker segments
of a Speech to Text job. The key names match the job
endpoints, so a poller and a webhook consumer read the same fields.

| Field             | Type             | Present    | Meaning                                                                                             |
| ----------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------- |
| `jobId`           | string           | Always     | The id the `POST` that started the job returned.                                                    |
| `apiKeyId`        | string           | Always     | The id of the API key that started the job.                                                         |
| `service`         | string           | Always     | `tts` or `stt`.                                                                                     |
| `durationSeconds` | number or `null` | Always     | Length of the audio. `null` when it was not measured.                                               |
| `text`            | string           | `tts` only | The text that was synthesised, echoed back so you can match the callback to your request.           |
| `audioUrl`        | string           | `tts` only | A signed, time-limited link to the audio. Download it when the event arrives; do not store the URL. |
| `transcription`   | string           | `stt` only | The transcript.                                                                                     |

A Speech to Text event carries `transcription` only. The `segments` and
`speakers` of a job made with `identify_speakers=true` are not in the event:
read them from `GET /v1/speech-to-text/jobs/{jobId}`, or download the job as
subtitles or JSON from `GET /v1/speech-to-text/jobs/{jobId}/export`.

Text to Speech example:

```json
{
  "jobId": "66e6b2f1c9a4d3e8f0a1b2c3",
  "apiKeyId": "66e6b1a0c9a4d3e8f0a1b2b9",
  "service": "tts",
  "durationSeconds": 3.42,
  "text": "مرحبا بكم في إيكونوڤا",
  "audioUrl": "https://storage.example.com/tts/tts-1a2b3c.wav?X-Amz-Signature=..."
}
```

Speech to Text example:

```json
{
  "jobId": "66e6b3d4c9a4d3e8f0a1b2c7",
  "apiKeyId": "66e6b1a0c9a4d3e8f0a1b2b9",
  "service": "stt",
  "durationSeconds": 12.8,
  "transcription": "مرحبا بكم في إيكونوڤا"
}
```

## job.failed

Sent after the job is marked failed and its credits are refunded.

| Field      | Type             | Present | Meaning                                                                                                                                |
| ---------- | ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `jobId`    | string           | Always  | The id the `POST` that started the job returned.                                                                                       |
| `apiKeyId` | string           | Always  | The id of the API key that started the job.                                                                                            |
| `service`  | string           | Always  | `tts` or `stt`.                                                                                                                        |
| `error`    | string or `null` | Always  | The reason the job failed. When none was given, `AI server generation failed` for `tts` or `AI server transcription failed` for `stt`. |

```json
{
  "jobId": "66e6b2f1c9a4d3e8f0a1b2c3",
  "apiKeyId": "66e6b1a0c9a4d3e8f0a1b2b9",
  "service": "tts",
  "error": "AI server generation failed"
}
```

## spending.warning

Sent once per billing cycle, the first time a key's usage reaches 80% of its
**Credit Limit**. It is sent again in the next cycle once the key's usage
has reset, which happens on the key's first metered call of that
cycle; see [Credits](/docs/developers/credits#credit-limit-per-key).

| Field            | Type   | Meaning                                                                                                                                                         |
| ---------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKeyId`       | string | The id of the API key.                                                                                                                                          |
| `apiKeyName`     | string | The same value as `apiKeyId`. The key's display name does not travel in the event; key your handling on `apiKeyId`.                                             |
| `creditsUsed`    | number | A whole number of credits. Credits recorded against the key in this billing cycle as of the start of this request, including the call that triggered the event. |
| `creditLimit`    | number | The key's **Credit Limit**, in credits.                                                                                                                         |
| `percentageUsed` | number | `creditsUsed / creditLimit * 100`, rounded to the nearest whole number.                                                                                         |

```json
{
  "apiKeyId": "66e6b1a0c9a4d3e8f0a1b2b9",
  "apiKeyName": "66e6b1a0c9a4d3e8f0a1b2b9",
  "creditsUsed": 820,
  "creditLimit": 1000,
  "percentageUsed": 82
}
```

## spending.exhausted

Sent each time a metered call leaves the key at or above its
**Credit Limit**. A call that would cross the limit is rejected with `429`
and `Monthly spending limit exhausted` before it runs, so this event follows
the call that lands exactly on the limit.

| Field         | Type   | Meaning                                                                                                                                                         |
| ------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKeyId`    | string | The id of the API key.                                                                                                                                          |
| `apiKeyName`  | string | The same value as `apiKeyId`. The key's display name does not travel in the event; key your handling on `apiKeyId`.                                             |
| `creditsUsed` | number | A whole number of credits. Credits recorded against the key in this billing cycle as of the start of this request, including the call that triggered the event. |
| `creditLimit` | number | The key's **Credit Limit**, in credits.                                                                                                                         |

```json
{
  "apiKeyId": "66e6b1a0c9a4d3e8f0a1b2b9",
  "apiKeyName": "66e6b1a0c9a4d3e8f0a1b2b9",
  "creditsUsed": 1001,
  "creditLimit": 1000
}
```

## FAQ

### How do I know which webhook or delivery a request belongs to?

The body carries no delivery id and no webhook id. Identify a job event by
`jobId` plus `X-Webhook-Event`, and a spending event by `apiKeyId` plus the
event name. De-duplication is on
[Delivery, retries and failures](/docs/developers/webhooks-retries).

### How do I show the key's name from a spending event?

`apiKeyName` carries the key's id. Learn each key's `apiKeyId` from the
`apiKeyId` field of a `job.completed` or `job.failed` event for a job you
started with that key, then keep your own map from id to the system that
holds the key.

### Does a retry change the body?

No. A retry sends the same body with a new `X-Webhook-Timestamp` and a new
`X-Webhook-Signature`.

### Do synchronous calls send job events?

No. `job.completed` and `job.failed` are sent for asynchronous jobs started
with an API key. Spending events are sent for both call styles.

### Is the audioUrl in the event permanent?

No. It is a signed, time-limited link. Download the file when the event
arrives; `GET /v1/text-to-speech/jobs/{jobId}` returns a fresh link if it
has expired.

## Related

- [Configure a webhook](/docs/developers/webhooks-configure)
- [Verify a webhook signature](/docs/developers/webhooks-verify-signature)
- [Delivery, retries and failures](/docs/developers/webhooks-retries)
- [Credits](/docs/developers/credits)
- [Get TTS job details](/docs/reference/tts-get-job)
- [Get STT job details](/docs/reference/stt-get-job)
