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

# Webhooks

> Receive an HTTP callback the moment a project reaches a milestone, instead of polling

# Webhooks

Webhooks let VoiceCheap notify your server as soon as something happens, so you can stop polling
`GET /v1/translate/{projectId}/status`.

<Note>
  Deliveries are attempted **once**. There are no retries yet, so keep polling as a safety net for
  anything you cannot afford to miss. Retries with exponential backoff are planned.
</Note>

## Setup

1. Open the [API page](https://voicecheap.ai/page-api) in your VoiceCheap account.
2. In **Webhooks**, select **Generate signing secret**. The secret starts with `whsec_` and is shown
   **once** — copy it and store it on your server.
3. Enter your **endpoint URL** and save it. It must use `https`.

That is all. Every project you start from then on delivers events to that endpoint.

### Overriding the endpoint per request

`POST /v1/translate` and `POST /v1/projects` accept an optional `webhookUrl` field that overrides the
account endpoint for that project only. It is handy for sending staging traffic somewhere else:

```bash theme={null}
curl -X POST "https://api.voicecheap.ai/v1/projects" \
  -H "x-api-key: vc_your-key" \
  -F "file=@interview.mp4" \
  -F "targetLanguage=german" \
  -F "webhookUrl=https://staging.your-server.com/voicecheap/webhooks"
```

The signing secret is always the account secret; only the destination changes.

## Events

| Event                           | Fires when                                                                             |
| ------------------------------- | -------------------------------------------------------------------------------------- |
| `project.created`               | The project exists and its transcript is stored. This is your cue to download the SRT. |
| `project.creation.failed`       | The project could not be created.                                                      |
| `project.translation.completed` | The translation finished and the outputs are ready.                                    |
| `project.translation.failed`    | The translation failed.                                                                |
| `project.lipsync.completed`     | Lip sync finished.                                                                     |
| `project.lipsync.failed`        | Lip sync failed.                                                                       |

<Note>
  There is no separate transcription event. Transcription runs inside project creation, and a project
  only exists once its transcript is stored — so `project.created` already means the transcript is
  ready to fetch with [`POST /v1/projects/{projectId}/transcript`](/docs/api-reference/project-transcript).
</Note>

## Payload

Every delivery is a `POST` with a JSON body:

```json theme={null}
{
  "type": "project.created",
  "eventId": "evt_9f2c4b1d8e7a4c3f9b2d1e0a5c6b7d8e",
  "projectId": "7fa7d3a3-4f2b-4c1e-9a6d-2b3c4d5e6f70",
  "workflow": "transcription",
  "status": "success",
  "targetLanguage": "german",
  "error": null,
  "occurredAt": "2026-08-19T09:12:00.000Z"
}
```

| Field            | Type           | Description                                                                                                         |
| ---------------- | -------------- | ------------------------------------------------------------------------------------------------------------------- |
| `type`           | string         | One of the events above.                                                                                            |
| `eventId`        | string         | Unique per event. Use it to deduplicate.                                                                            |
| `projectId`      | string         | The project this event belongs to.                                                                                  |
| `workflow`       | string         | `transcription` or `translation`.                                                                                   |
| `status`         | string         | `success` or `failed`.                                                                                              |
| `targetLanguage` | string         | Present only on language specific events.                                                                           |
| `error`          | object \| null | On `*.failed` events, the same `{ code, message }` shape as the REST API. See [Error Codes](/docs/api-reference/errors). |
| `occurredAt`     | string         | ISO 8601 timestamp.                                                                                                 |

The payload is intentionally thin. Call the REST API for the content itself.

## Verifying the signature

Every delivery carries these headers:

| Header                   | Description                                                                |
| ------------------------ | -------------------------------------------------------------------------- |
| `X-VoiceCheap-Signature` | `sha256=<hex>` — HMAC-SHA256 of the payload, keyed by your signing secret. |
| `X-VoiceCheap-Timestamp` | Unix seconds, included in the signed string so you can reject replays.     |
| `X-VoiceCheap-Event-Id`  | Same value as `eventId` in the body, useful in support requests.           |

The signature covers `<timestamp>.<raw body>`, so it is different on every delivery and proves both
that the request came from VoiceCheap and that the body was not modified in transit.

```js theme={null}
import crypto from 'crypto';
import express from 'express';

const app = express();

// The raw body is required: parsing and re-serializing changes the bytes and the signature will
// never match. This is the single most common cause of failed verification.
app.post('/voicecheap/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const timestamp = req.header('X-VoiceCheap-Timestamp');
  const signature = req.header('X-VoiceCheap-Signature')?.replace('sha256=', '') ?? '';

  // Reject anything older than five minutes.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(400).send('Stale timestamp');
  }

  const expected = crypto
    .createHmac('sha256', process.env.VOICECHEAP_WEBHOOK_SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const isValid =
    expected.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));

  if (!isValid) return res.status(401).send('Invalid signature');

  const event = JSON.parse(rawBody);
  // Acknowledge quickly, then process asynchronously.
  res.status(200).send('ok');
  handleEvent(event);
});
```

<Warning>
  Verify the signature before trusting a delivery. Your endpoint is a public URL, and the signature is
  what distinguishes a real VoiceCheap event from anything else that reaches it.
</Warning>

## Responding

Reply with any `2xx` status to acknowledge. Answer within **10 seconds** — acknowledge first and do
the work afterwards, rather than processing before you reply.

A non-2xx response or a timeout is recorded as a failed delivery and, for now, is not retried.

## Rotating the secret

Select **Rotate** on the API page to generate a new secret. The previous one stops working
immediately, so deploy the new secret to your server as soon as you rotate.

Deleting the webhook removes both the endpoint and the secret, and deliveries stop.
