Skip to content

Webhooks

View .md

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 instead.

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.

  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
ngrok http 3000
POST <your payload URL>
{
"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.

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.

verify.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);
}
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.

api/comvi-webhook.ts
import { verifyComviWebhook } from './verify';
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.