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.
Events
Section titled “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
Section titled “Webhook object”{ "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
Section titled “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 |
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/projects/5/webhooksGET /v1/webhooks/{id}
Section titled “GET /v1/webhooks/{id}”Read one webhook. webhook.view.
| Parameter | In | Type | Required | Meaning |
|---|---|---|---|---|
id |
path | integer |
Yes | Webhook ID |
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1POST /v1/projects/{projectId}/webhooks
Section titled “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 |
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/webhooksReturns 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}
Section titled “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 |
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/2Returns 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}
Section titled “DELETE /v1/webhooks/{id}”Remove a webhook. No further events are delivered. webhook.delete.
curl -X DELETE -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/2Returns 204 No Content.
POST /v1/webhooks/{id}/test
Section titled “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.
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1/test{ "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
Section titled “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.
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1/regenerate-secret{ "secret": "whsec_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3" }GET /v1/webhooks/{id}/deliveries
Section titled “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 |
curl -H "Authorization: Bearer $COMVI_API_KEY" "https://api.comvi.io/v1/webhooks/1/deliveries?status=failed&limit=2"{ "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}
Section titled “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 |
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/deliveries/118Returns 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
Section titled “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 |
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
Section titled “GET /v1/webhooks/{id}/stats”Delivery counts for one webhook. webhook.view. Deliveries still pending or retrying count in total only.
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1/stats{ "total": 34, "successful": 31, "failed": 3 }Payload and signature
Section titled “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.
{ "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.
import crypto from 'node:crypto';
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 Bufferapp.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
Section titled “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
Section titled “Next steps”- Webhooks in the platform to configure from the dashboard
- Translations for the endpoints that fire translation events
- Import/Export for import and export events