# Webhooks

Register webhook subscriptions, inspect and retry deliveries, and verify signatures through the Comvi REST API.

Canonical: https://comvi.io/docs/api/webhooks

A webhook is a URL that receives a POST whenever a subscribed event fires in a project.

Every endpoint accepts a session cookie or an API key. A key needs the matching permission stored on it: `webhook.view` to read, `webhook.create` to create, `webhook.edit` to update, test, retry or regenerate, `webhook.delete` to delete. None of the six named scopes covers webhooks, so a key built from the scope list alone gets `403 FORBIDDEN`; request the permission by name when creating the key, and only a permission you hold.

Common errors: `401 AUTHENTICATION_REQUIRED`, `403 FORBIDDEN` (missing the `webhook.*` permission), `403 READ_ONLY_MODE` on writes, `404 WEBHOOK_NOT_FOUND` on `/v1/webhooks/{id}` routes (no such webhook, or a project you cannot see), `422 VALIDATION_ERROR` when a path parameter is not a positive integer. See [Errors](/docs/api/errors).

## Events

| Event | Fires when |
|-------|-----------|
| `translation.created` | A translation value was added |
| `translation.updated` | A translation value was changed, including clearing it |
| `translation.batch_updated` | Several translations changed at once, including a batch clear |
| `key.created` | A key was created |
| `key.updated` | A key was renamed or modified |
| `key.deleted` | A key was deleted |
| `namespace.created` | A namespace was created |
| `namespace.updated` | A namespace was renamed |
| `namespace.deleted` | A namespace was deleted |
| `import.completed` | An import finished |
| `export.completed` | An export finished |
| `comment.created` | A comment was added to a translation |
| `project.locale_added` | A language was added |
| `project.locale_removed` | A language was removed |
| `webhook.test` | A test delivery was triggered |
| `webhook.disabled` | The webhook was disabled after 10 consecutive failures, or turned off by hand |

That list is the whole enum; any other string in `events` fails with `422`.

## Webhook object

```json
{
"id": 1, "uuid": "wh_3f9a1c2e4b5d6078a9b0c1d2e3f4a5b6", "organizationId": 1, "projectId": 5,
"name": "Translation Updates", "url": "https://example.com/webhooks/comvi",
"events": ["translation.created", "translation.updated"], "isActive": true,
"consecutiveFailures": 0, "lastTriggeredAt": "2026-03-25T10:00:00Z", "lastSuccessAt": "2026-03-25T10:00:00Z",
"createdBy": 42, "createdAt": "2026-01-15T08:00:00Z", "updatedAt": "2026-01-15T08:00:00Z"
}
```

Callers without `webhook.edit` see `url` redacted to protocol and host. `secret` is returned only by create and regenerate.

### `GET /v1/projects/{projectId}/webhooks`

List a project's webhooks as a bare array. `webhook.view`.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/projects/5/webhooks
```

### `GET /v1/webhooks/{id}`

Read one webhook. `webhook.view`.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `id` | path | `integer` | Yes | Webhook ID |

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1
```

### `POST /v1/projects/{projectId}/webhooks`

Register a webhook. `webhook.create`. The response carries `secret` once; store it. Comvi resolves the URL before saving and refuses loopback, link-local and private addresses, URLs carrying credentials, and schemes other than `http` and `https`. Over plain HTTP the payload travels in the clear, so use HTTPS.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `name` | body | `string` | Yes | 1 to 255 characters |
| `url` | body | `string` | Yes | 1 to 2048 characters, matching `^https?://.+` |
| `events` | body | `string[]` | Yes | At least one event from the table |
| `isActive` | body | `boolean` | No | Default `true` |

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "Translation Sync", "url": "https://example.com/webhooks/comvi", "events": ["translation.created", "key.deleted"]}' \
https://api.comvi.io/v1/projects/5/webhooks
```

Returns `201 Created` with the webhook object plus `"secret": "whsec_…"`.

| Status and code | When |
|-----------------|------|
| `400 WEBHOOK_URL_NOT_ALLOWED` | The URL resolves to a blocked or unreachable address |
| `404 PROJECT_NOT_FOUND` | No such project |
| `422 VALIDATION_ERROR` | An event is not in the enum, or `events` is empty. `meta.field` is `events.0`, no `message` |
| `422 FIELD_REQUIRED` | `name`, `url` or `events` is missing |
| `422 FIELD_TOO_LONG` | `name` over 255, or `url` over 2048 |
| `422 FIELD_TOO_SHORT` | `name` or `url` is empty |

### `PATCH /v1/webhooks/{id}`

Update name, URL, events or active status. Omitted fields keep their value. `webhook.edit`. Switching `isActive` to `false` records a `webhook.disabled` event with `reason: "manual"`.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `id` | path | `integer` | Yes | Webhook ID |
| `name` | body | `string` | No | 1 to 255 characters |
| `url` | body | `string` | No | Revalidated as on create |
| `events` | body | `string[]` | No | Replacement list, at least one |
| `isActive` | body | `boolean` | No | Enable or disable |

```bash
curl -X PATCH -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"events": ["translation.created", "translation.updated", "key.created"], "isActive": true}' \
https://api.comvi.io/v1/webhooks/2
```

Returns the updated webhook object.

| Status and code | When |
|-----------------|------|
| `400 WEBHOOK_URL_NOT_ALLOWED` | The new URL resolves to a blocked or unreachable address |
| `422 VALIDATION_ERROR` | An event is not in the enum, or `events` is empty |

### `DELETE /v1/webhooks/{id}`

Remove a webhook. No further events are delivered. `webhook.delete`.

```bash
curl -X DELETE -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/2
```

Returns `204 No Content`.

### `POST /v1/webhooks/{id}/test`

Queue a `webhook.test` delivery. `webhook.edit`. The response comes back before the attempt, so `status` is `pending` and the result fields are `null`; poll `GET /v1/deliveries/{id}` with the returned `id`.

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1/test
```

```json
{
"id": 100, "uuid": "del_7c1e9a0b2d4f6183a5c7e9b1d3f5a7c9", "webhookId": 1, "event": "webhook.test",
"payload": { "event": "webhook.test", "timestamp": "2026-03-25T12:00:00.000Z", "deliveryId": "del_7c1e9a0b2d4f6183a5c7e9b1d3f5a7c9", "data": { "message": "This is a test webhook from TMS" } },
"status": "pending", "httpStatus": null, "attemptNumber": 1, "responseBody": null, "errorMessage": null,
"durationMs": null, "sentAt": null, "completedAt": null, "nextRetryAt": null, "createdAt": "2026-03-25T12:00:00Z"
}
```

### `POST /v1/webhooks/{id}/regenerate-secret`

Issue a new signing secret. `webhook.edit`. The worker reads the secret at delivery time, so queued and retrying deliveries go out under the new secret. Rotate in this order: teach the receiver the new secret alongside the old one, call this endpoint, then drop the old secret. Rotating first drops every queued delivery.

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1/regenerate-secret
```

```json
{ "secret": "whsec_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3" }
```

### `GET /v1/webhooks/{id}/deliveries`

Page through delivery attempts, newest first. `webhook.view`. `payload` and `responseBody` are `null` when your permissions do not cover the event, and `responseBody` is truncated to 2000 bytes.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `id` | path | `integer` | Yes | Webhook ID |
| `limit` | query | `integer` | No | 1 to 100, default 20 |
| `cursor` | query | `string` | No | `nextCursor` from the previous page. Base64, `^[A-Za-z0-9+/]+={0,2}$` |
| `status` | query | `string` | No | `pending`, `success`, `failed` or `retrying` |

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" "https://api.comvi.io/v1/webhooks/1/deliveries?status=failed&limit=2"
```

```json
{
"deliveries": [
    { "id": 118, "uuid": "del_9f2c1a7b4e6d80351c2b3a4d5e6f7a8b", "webhookId": 1, "event": "translation.updated", "payload": null, "status": "failed",
      "httpStatus": 500, "attemptNumber": 5, "responseBody": "upstream error", "errorMessage": "HTTP 500", "durationMs": 212,
      "sentAt": "2026-03-25T13:10:00Z", "completedAt": "2026-03-25T13:10:01Z", "nextRetryAt": null, "createdAt": "2026-03-25T12:00:00Z" }
],
"nextCursor": "MTE4", "hasMore": true, "total": 34
}
```

| Status and code | When |
|-----------------|------|
| `400 INVALID_INPUT` | `cursor` is not one this endpoint issued |

### `GET /v1/deliveries/{id}`

Read one delivery, with the payload sent and the response received. `webhook.view`.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `id` | path | `integer` | Yes | Delivery ID, the `id` of a delivery object |

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/deliveries/118
```

Returns one delivery object.

| Status and code | When |
|-----------------|------|
| `404 WEBHOOK_DELIVERY_NOT_FOUND` | No such delivery, or a project you cannot see |

### `POST /v1/deliveries/{id}/retry`

Re-queue a delivery that ran out of automatic retries. `webhook.edit`. It goes back to `pending` and `attemptNumber` increases by one.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `id` | path | `integer` | Yes | Delivery ID |

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/deliveries/118/retry
```

| Status and code | When |
|-----------------|------|
| `400 WEBHOOK_RETRY_NOT_ALLOWED` | The delivery is not in the `failed` state |
| `404 WEBHOOK_DELIVERY_NOT_FOUND` | No such delivery, or a project you cannot see |

### `GET /v1/webhooks/{id}/stats`

Delivery counts for one webhook. `webhook.view`. Deliveries still `pending` or `retrying` count in `total` only.

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1/stats
```

```json
{ "total": 34, "successful": 31, "failed": 3 }
```

## Payload and signature

Every delivery is a POST with `Content-Type: application/json`. `event`, `timestamp` and `deliveryId` are on every payload; `data` is what the event emitted. Comvi waits 10 seconds for a response: reply `2xx` once the event is stored and do slow work afterwards.

```json
{ "event": "translation.updated", "timestamp": "2026-03-25T12:00:00.000Z", "deliveryId": "del_9f2c1a7b4e6d8035", "data": { "keyId": 812, "localeCode": "fr" } }
```

| Header | Value |
|--------|-------|
| `X-TMS-Signature` | `t=<unix seconds>,v1=<hex hmac>` |
| `X-TMS-Event` | The `event` value, for routing without parsing the body |
| `X-TMS-Delivery-Id` | The `deliveryId` value, for idempotent handlers |
| `User-Agent` | `TMS-Webhooks/1.0` |

`v1` is `HMAC-SHA256(secret, "<t>.<raw request body>")` in lowercase hex. Verify against the raw body, unchanged: re-serializing the parsed JSON can shift key order or escaping. Reject timestamps older than a tolerance, five minutes is a reasonable default, unless you retry deliveries by hand long after they were signed.

```javascript

function verify(rawBody, header, secret, toleranceSeconds = 300) {
const { t, v1 } = Object.fromEntries(String(header || '').split(',').map((p) => p.split('=')));
if (!t || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(v1, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the body as a Buffer
app.post('/webhooks/comvi', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verify(rawBody, req.headers['x-tms-signature'], process.env.COMVI_WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
}
const { event, data, deliveryId } = JSON.parse(rawBody);
res.status(200).send('OK');
});
```

## Retries

A non-2xx response, a 10-second timeout or an unreachable host schedules a retry. Each delay is jittered by up to 10%.

| Attempt | Delay before it |
|---------|-----------------|
| 1 | none |
| 2 | ~1 minute |
| 3 | ~5 minutes |
| 4 | ~15 minutes |
| 5 | ~1 hour |

After the fifth failure the delivery stays `failed` and can be re-queued with `POST /v1/deliveries/{id}/retry`. A URL that resolves to a blocked address is not retried; a failed DNS lookup is. After 10 consecutive failures the webhook is disabled and a `webhook.disabled` event is recorded with `reason: "max_consecutive_failures"`; turn it back on with `{"isActive": true}`. A success resets `consecutiveFailures` to `0`.

## Next steps

- [Webhooks in the platform](/docs/platform/integrations/webhooks) to configure from the dashboard
- [Translations](/docs/api/translations) for the endpoints that fire translation events
- [Import/Export](/docs/api/import-export) for import and export events
