Skip to content

Webhooks

Webhooks are how Comvi tells your systems something happened. When a matching event fires in a project, Comvi sends a signed HTTP POST to the URL you registered. Use them to trigger CI builds, invalidate caches, post to Slack, or wire Comvi into any internal pipeline.

  • You want a CI build to run when content changes
  • You want to mirror content changes into another system (analytics, search index, legal archive)
  • You want a team channel alert on specific activity
  • You want to drive workflow automation (“translator finished a namespace → notify reviewer”)

If your need is “load fresh translations in my app”, a webhook is not the right tool — apps should read the CDN directly.

EventFires when
translation.createdA target-language value is first set on a key
translation.updatedAn existing target value is edited
translation.batch_updatedA bulk operation updated many values at once (MT, TM apply, bulk status change)
key.createdA new key is added
key.updatedA key’s metadata or source value changes
key.deletedA key is removed
namespace.createdA new namespace is added
namespace.updatedA namespace is renamed or re-described
namespace.deletedA namespace is removed
import.completedAn import job finishes
export.completedAn export is generated
comment.createdA comment is posted on a translation value
project.locale_addedA language is added to the project
project.locale_removedA language is removed from the project
webhook.testFired when you click Send Test
webhook.disabledComvi auto-disabled this webhook after too many failures
  1. Open webhook settings

    Project Settings → Webhooks. Click Create Webhook.

  2. Fill in the basics

    • Name — human-readable label for the dashboard list
    • URL — your public HTTP or HTTPS endpoint. HTTPS is strongly recommended for production.
    • Events — tick the subset you care about (at least one required)
  3. Copy the secret

    Comvi generates a signing secret (format whsec_<64 hex>). It is shown once — copy it into your server’s config now. You can regenerate later if you lose it, which invalidates the old one.

  4. Test delivery

    Click Send Test. Comvi dispatches a webhook.test payload synchronously and shows the remote status code, response body, and duration. Iterate here until you get a 2xx.

Every delivery is a JSON body with the same envelope:

{
"event": "translation.updated",
"deliveryId": "del_a1b2c3d4e5f6…",
"timestamp": "2026-04-15T14:30:00.000Z",
"projectId": 42,
"data": {
"translation": {
"id": 456,
"keyId": 123,
"key": "nav.home",
"namespace": "default",
"locale": "de",
"value": "Startseite",
"status": "translated"
}
}
}

The envelope (event, deliveryId, timestamp, projectId) is identical across event types. data varies by event. The dashboard’s Webhook Events reference lists the exact payload shape for each event type — check it when wiring up a new handler.

Every request carries an X-TMS-Signature header in Stripe-compatible format:

X-TMS-Signature: t=<unix-seconds>,v1=<hex-hmac>

The signed string is <timestamp>.<raw-body>, signed with HMAC-SHA256 using your webhook secret, hex-encoded. Comvi also sends X-TMS-Event and X-TMS-Delivery-Id. Verify the signature before trusting the payload.

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);
}
  • Timeout per attempt: 10 seconds. If your endpoint hasn’t responded by then, the attempt is marked failed.

  • Success: any 2xx response.

  • Retries: up to 5 attempts total (1 initial + 4 retries) with exponential backoff plus ±10% jitter:

    AttemptDelay after previous failure
    2~1 minute
    3~5 minutes
    4~15 minutes
    5~1 hour
    (stop)
  • Auto-disable: after 10 consecutive failures across events, Comvi disables the webhook and fires a webhook.disabled event on any other webhooks subscribed to it. The disabled webhook stops receiving events until you fix the endpoint and re-enable it in the dashboard.

You can view and manually re-dispatch any past delivery from Project Settings → Webhooks → <your webhook> → Deliveries.

Retries mean your handler may receive the same event twice. Always treat handlers as idempotent:

  • Dedupe by deliveryId if strict exactly-once matters
  • Otherwise, ensure the side effect is safe to repeat (e.g. “trigger a CI build” is safe; “charge a credit card” would not be)

Webhooks need an HTTPS URL your local server can receive at. Use a tunneling tool:

Terminal window
ngrok http 3000
# Copy the HTTPS URL ngrok prints and register it as the webhook URL

Click Send Test in the dashboard to fire a webhook.test payload without waiting for a real event.

Trigger a Vercel rebuild on content change

Section titled “Trigger a Vercel rebuild on content change”

Subscribe to translation.created, translation.updated, and translation.batch_updated — and/or key.* events if you want rebuilds on source-key changes too — then call a Vercel Deploy Hook:

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 } = JSON.parse(raw);
if (event.startsWith('translation.') || event.startsWith('key.')) {
await fetch(process.env.VERCEL_DEPLOY_HOOK_URL, { method: 'POST' });
}
res.status(200).end();
}

Debounce on your CI side or inside this handler if you expect bursts (a bulk translate can fire translation.batch_updated once for the whole batch).

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.keysCreated} created, ${data.keysUpdated} updated.`,
}),
});
}
  • Per-attempt timeout: 10 s
  • Total attempts per event: 5
  • Auto-disable threshold: 10 consecutive failures
  • URL protocol: HTTP or HTTPS accepted; HTTPS strongly recommended for production

Signature check fails but secret is correct

Section titled “Signature check fails but secret is correct”

You’re probably re-serializing the body before verifying. Capture the raw body before your JSON parser runs.

Retries — normal. Dedupe on deliveryId or make the handler idempotent.

Check the deliveries list for the failure reason. Fix your endpoint (5xx responses? timeouts? SSL issues?), then toggle the webhook active again in the dashboard.

I need an event for “translations were published”

Section titled “I need an event for “translations were published””

Not available today. Track translation.batch_updated or poll the CDN. Star this request — it is on the roadmap.