Skip to content

Webhooks

View .md

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.

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.

{
"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.

List a project’s webhooks as a bare array. webhook.view.

Parameter In Type Required Meaning
projectId path integer Yes Project ID
Terminal window
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/projects/5/webhooks

Read one webhook. webhook.view.

Parameter In Type Required Meaning
id path integer Yes Webhook ID
Terminal window
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1

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
Terminal window
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

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
Terminal window
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

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

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

Returns 204 No Content.

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.

Terminal window
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"
}

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.

Terminal window
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1/regenerate-secret
{ "secret": "whsec_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3" }

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
Terminal window
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

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
Terminal window
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

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
Terminal window
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

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

Terminal window
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/webhooks/1/stats
{ "total": 34, "successful": 31, "failed": 3 }

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 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');
});

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.