# Webhooks

Signed HTTP callbacks when content changes in a project: the event catalog, payload envelope, signature verification, retries and auto-disable.

Canonical: https://comvi.io/docs/platform/integrations/webhooks

When a subscribed event fires in a project, Comvi sends a signed HTTP `POST` to the URL you
registered. Use webhooks to trigger CI, mirror changes into another system, or alert a team
channel. Apps that need fresh translations read the [CDN](/docs/platform/cdn) instead.

## Events

| Event | Fires when |
|---|---|
| `translation.created` | A target-language value is first set on a key |
| `translation.updated` | An existing target value is edited |
| `translation.batch_updated` | A bulk operation updated many values at once (MT, TM apply, bulk status change) |
| `key.created` | A new key is added |
| `key.updated` | A key's metadata or source value changes |
| `key.deleted` | A key is removed |
| `namespace.created` | A new namespace is added |
| `namespace.updated` | A namespace is renamed or re-described |
| `namespace.deleted` | A namespace is removed |
| `import.completed` | An import job finishes |
| `export.completed` | An export is generated |
| `comment.created` | A comment is posted on a translation value |
| `project.locale_added` | A language is added to the project |
| `project.locale_removed` | A language is removed from the project |
| `webhook.test` | You click **Test** |
| `webhook.disabled` | Comvi auto-disabled a webhook after too many failures |

There is no deploy event. To drive CI on content changes, subscribe to
`translation.batch_updated` plus the key and translation events, debounce on your side, or
poll the CDN for a new bundle.

## Create a webhook

1. Open **Settings → Webhooks** in the project sidebar and click **Create Webhook**.

2. Enter a **Name**, the **Payload URL** (HTTPS), and tick at least one event.

3. Copy the signing secret, `whsec_<64 hex>`. It is shown once. **Regenerate** in the row
   menu issues a new one and invalidates the old.

4. Click **Test** in the row menu. Comvi queues a `webhook.test` delivery and returns it as
   `pending`; open **View Deliveries** to see the status code, response body and duration
   once the worker has run.

For local development, expose your server with a tunnel and register its HTTPS URL:

`Terminal`

```bash
ngrok http 3000
```

## Payload

`POST <your payload URL>`

```json
{
"event": "translation.updated",
"timestamp": "2026-04-15T14:30:00.000Z",
"deliveryId": "del_a1b2c3d4e5f6…",
"data": {
    "translation": {
      "id": 456,
      "keyId": 123,
      "key": "nav.home",
      "namespace": "default",
      "locale": "de",
      "value": "Startseite",
      "status": "translated"
    }
}
}
```

`event`, `timestamp` and `deliveryId` are identical across event types; `data` varies per
event. There is no `projectId` in the body: give each project its own webhook URL, or read
the delivery's webhook registration to tell them apart.

## Verify the signature

```text
X-TMS-Signature: t=<unix-seconds>,v1=<hex-hmac>
```

The signed string is `<timestamp>.<raw-body>`, HMAC-SHA256 with the webhook secret,
hex-encoded. Comvi also sends `X-TMS-Event` and `X-TMS-Delivery-Id`.
**Node.js**

`verify.ts`

```ts
import crypto from 'node:crypto';

export function verifyComviWebhook(
    rawBody: string,
    header: string,
    secret: string,
    toleranceSeconds = 300,
): boolean {
    const parts = Object.fromEntries(
      header.split(',').map((p) => p.split('=') as [string, string]),
    );
    const { t, v1 } = parts;
    if (!t || !v1) return false;

    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
    if (age > toleranceSeconds) return false;

    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${t}.${rawBody}`)
      .digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(v1);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Python**

`verify.py`

```python
import hmac, hashlib, time

def verify_comvi_webhook(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
      parts = dict(p.split("=", 1) for p in header.split(","))
      t, v1 = parts.get("t"), parts.get("v1")
      if not t or not v1:
          return False
      if abs(int(time.time()) - int(t)) > tolerance:
          return False
      expected = hmac.new(
          secret.encode(),
          f"{t}.".encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(expected, v1)
```

> **Raw body, constant-time compare**
>
> Verify against the raw request body captured before your JSON parser runs; re-serializing
> changes whitespace and key order and breaks the HMAC. Compare with
> `crypto.timingSafeEqual` or `hmac.compare_digest`, never string equality.

## Delivery and retries

| Attempt | Delay after the previous failure |
|---|---|
| 2 | about 1 minute |
| 3 | about 5 minutes |
| 4 | about 15 minutes |
| 5 | about 1 hour |

Each attempt times out after 10 seconds, any 2xx counts as success, and a failed event is
retried up to 5 attempts in total with exponential backoff and ±10% jitter. After 10
consecutive failures across events, Comvi disables the webhook and fires `webhook.disabled`
on any other webhook subscribed to it; fix the endpoint and toggle the webhook active again
in the dashboard. **View Deliveries** lists past deliveries and can re-dispatch one.

Retries mean a handler can receive the same event twice. Dedupe by `deliveryId`, or make
the side effect safe to repeat: triggering a CI build is, charging a card is not.

## Examples

`api/comvi-webhook.ts`

```ts

export default async function handler(req, res) {
const raw = await readRaw(req);
if (!verifyComviWebhook(raw, req.headers['x-tms-signature'], SECRET)) {
    return res.status(401).end();
}

const { event, data } = JSON.parse(raw);
if (event.startsWith('translation.') || event.startsWith('key.')) {
    await fetch(process.env.VERCEL_DEPLOY_HOOK_URL, { method: 'POST' });
}
if (event === 'import.completed') {
    await fetch(process.env.SLACK_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `Import finished: ${data.import.added} added, ${data.import.updated} updated.`,
      }),
    });
}
res.status(200).end();
}
```

Subscribe to `translation.created`, `translation.updated` and `translation.batch_updated`
for a rebuild on content changes, and add `key.*` for source-key changes. A bulk translate
fires `translation.batch_updated` once for the whole batch; debounce single edits on your
side.

## Next steps

- [Content Deploy / CDN](/docs/platform/cdn): What a deploy does, and why there is no deploy event to wait for.
- [CI/CD integration](/docs/platform/recipes/ci-cd-integration): Start a pipeline from a webhook or run the CLI on every build.
