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 instead.
Events
Section titled “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
Section titled “Create a webhook”-
Open Settings → Webhooks in the project sidebar and click Create Webhook.
-
Enter a Name, the Payload URL (HTTPS), and tick at least one event.
-
Copy the signing secret,
whsec_<64 hex>. It is shown once. Regenerate in the row menu issues a new one and invalidates the old. -
Click Test in the row menu. Comvi queues a
webhook.testdelivery and returns it aspending; 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:
ngrok http 3000Payload
Section titled “Payload”{ "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
Section titled “Verify the signature”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.
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);}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)Delivery and retries
Section titled “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
Section titled “Examples”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.