Skip to main content

Webhook events

Taia can send an HTTP callback to a URL of your choice when something happens to a project, an order or a set of strings. It saves you polling for a status that rarely changes.

Each webhook belongs to an API key, so create the key first. See Create an API key.

Create a webhook​

curl -X POST https://api.taia.io/api/public/v1/webhooks \
-H "Authorization: Api-Key taia_pk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/taia",
"events": ["project.mt_completed", "order.delivered"],
"description": "Production listener"
}'

The response carries a secret, and that is the only time you see it. Save it: you need it to verify that a callback really came from us. If it's lost, regenerate it.

A few limits worth knowing before you build against it:

  • Five webhooks per API key.
  • The URL must use HTTPS.
  • One webhook per URL per key. Posting the same URL twice returns 409 with the existing webhook's id.
  • PATCH /api/public/v1/webhooks/{id} changes the URL, the event list or is_active. Sending an event name we do not recognise returns 400 and lists the ones we do, so a typo fails at subscribe time rather than silently never firing.

Event types​

Projects​

EventSent when
project.convertedThe uploaded files have been converted and the project is ready to translate
project.analysis_completedWord count and analysis have finished
project.quote_readyA professional translation quote is ready for approval
project.mt_completedAI translation has finished for the project
project.failedThe project failed. When the failure came from a processing task the payload adds error_message and file_name

Orders​

EventSent when
order.createdAn order has been placed, whichever way it was paid for, as long as either the project was created through the API or the order was placed through it
order.deliveredThe order has been delivered
order.cancelledThe order has been cancelled

Strings​

For continuously translated app and website strings, rather than documents. These are the ones a connector or a CI job wants.

EventSent when
strings.updatedKeys were added, updated or deleted
strings.translatedAI drafts are ready to preview. The payload counts them per language
strings.correctedA translation was corrected through the /correct endpoint
strings.approvedA translation was approved, so it is safe to write into your production branch

strings.approved is the one to gate a merge on. strings.translated only means a draft exists.

Subscribe to these through the API. The app's event picker offers the project.* and order.* events only, so the Strings ones are not selectable there yet.

note

project.created, order.submitted, order.assigned, order.in_progress and order.in_review are accepted if you subscribe to them, but nothing sends them at the moment. Do not build on them yet. Use order.created and order.delivered for the two ends of an order, and poll if you need the states in between.

Which webhooks receive an event​

An event does not go to every webhook on the account. Taia first works out whose API keys are involved, then which of their webhooks asked for that event.

  • A project created through the API sends its events only to webhooks on the API key that created it.
  • A project created in the app sends its events to webhooks on the owner's API keys, and on the keys of any team the project belongs to.
  • Order events need an API key on either side: the project was created through the API, or the order was placed through it with POST /api/public/v1/projects/{id}/order. Unlike the project events, they have no fallback to the owner's or the team's keys, so an order both created and placed in the app sends nothing at all.

Trigger Sources then filters what is left. Each webhook is set to accept api events, ui events, or both, and every event carries a source. What the source means is not the same for every event, and it is the easiest thing on this page to get wrong:

  • project.* and order.* carry the source the project or order was created with. Not who triggered the change. A project created in the app stays ui for its whole life, even when an API call is what moved it on. So if you create projects in the app and drive them by API, a webhook set to API only will never fire for them.
  • strings.* carries the source of the call that made the change. Editing a key with an API key is api; a reviewer approving a translation in the editor is ui. So a webhook set to API only will not see strings.approved from the editor, which is the one event you would gate a merge on. If in doubt, accept both.

Two more things worth knowing:

  • Webhooks created through the API accept both sources, because the create call has no trigger-sources field.
  • order.delivered and order.cancelled are sent when Taia's team updates the order. Those carry the source admin and reach the webhook whatever its Trigger Sources say.

Managing webhooks​

Everything the app does is on the API too, under Authorization: Api-Key <your key>:

MethodPathWhat it does
POST/api/public/v1/webhooksCreate one. The response carries the secret, once.
GET/api/public/v1/webhooksList the key's webhooks.
GET/api/public/v1/webhooks/{id}Read one.
PATCH/api/public/v1/webhooks/{id}Change url, events, description or is_active.
POST/api/public/v1/webhooks/{id}/rotate-secretReplace the secret. The response carries the new secret, once.
DELETE/api/public/v1/webhooks/{id}Delete one.
GET/api/public/v1/webhooks/{id}/deliveriesDelivery history, with the response code we got.
POST/api/public/v1/webhooks/{id}/deliveries/{delivery_id}/retryReplay a delivery that still has attempts left.

You can do all of it in the app instead. Configure a webhook walks through the screens, including the delivery history and the retry button.

Payload format​

Every callback is a POST with Content-Type: application/json and the same envelope: the event name, the time we sent it, and a data object whose contents depend on the event. data.source tells you where the change came from: api, ui, or admin when Taia's team updated an order.

Example: strings.approved

{
"event": "strings.approved",
"timestamp": "2026-09-28T12:00:00+00:00",
"data": {
"project_id": "3f7c1b28-0f2e-4a53-9a7d-4a1f1b9c2d55",
"translation_version": 42,
"tu_id": "b2d4e6f8-1a3c-5e7f-9b1d-3f5a7c9e1b3d",
"target_language": "de-DE",
"key": "checkout.button.pay",
"source": "api"
}
}

The other events carry their own fields, and this page does not list them yet (taia-ops #306). Key off event, and treat any field not documented here as optional.

Every strings.* payload carries project_id and translation_version, and that version is your delta cursor:

GET /api/v1/strings/{project_id}/export?language=de-DE&since_version=41&status=approved

since_version is exclusive. It returns the keys stamped after the version you pass, so pass the last version you successfully applied, not the one on the callback you are handling right now. Get that wrong by one and you skip the change the callback was telling you about: the callback above reports version 42, and asking for since_version=42 answers {"translations": {}, "unchanged": true}.

Add status=approved when you are writing into a production branch. The default is all, which returns every translated string whatever its review state, including drafts and ones somebody has rejected.

A delta response adds three fields a full export does not have, and you need all three:

  • delta: true, so you can tell a delta from a full export.
  • removed, the keys whose row is gone from this language. Stop serving these.
  • not_ready, the keys that changed but have no usable translation yet. Hold what you already have for these, rather than treating them as removed.

One thing a delta cannot tell you: a deleted key. Deleting a key removes its row outright, so there is nothing left to stamp with a version and it appears in neither removed nor not_ready. A strings.updated callback does fire on a delete, but a connector that only ever polls deltas will keep serving a key that no longer exists. Run a full export (no since_version) periodically to catch those.

Verify the signature​

We sign the exact bytes of the request body with HMAC-SHA256, using your webhook's secret, and send the digest as lowercase hex in X-Webhook-Signature.

import hashlib, hmac

def is_from_taia(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)

Two things make the difference between this working and not. Compute the digest over the body bytes exactly as they arrived, before parsing the JSON: we sign compact JSON with no spaces, so re-serializing a parsed object gives a different digest. And compare with compare_digest rather than ==.

When the check fails, reply with 401 (any non-2xx will do). A 2xx tells us the delivery arrived, so we won't send it again, even if your handler threw it away.

These headers come with every callback:

HeaderWhat it tells you
X-Webhook-EventThe event name, so you can route without parsing the body
X-Webhook-Delivery-IDThe delivery's id. The same id on two requests means a retry, not a second event
X-Webhook-AttemptWhich attempt this is, starting at 1
User-AgentAlways Taia-Webhooks/1.0, if you want to allow us through at the firewall

Use the delivery id to make your handler idempotent. A retry is a repeat of the same event, not a new one.

Every callback is signed, and there is no unsigned mode to switch on. The signature is the only thing that tells your endpoint a request came from us and not from anyone who found the URL, so keep the check in production.

Rotate the secret​

If a secret is lost or may have leaked, replace it:

curl -X POST https://api.taia.io/api/public/v1/webhooks/WEBHOOK_ID/rotate-secret \
-H "Authorization: Api-Key taia_pk_live_YOUR_KEY"

The response carries the new secret, once, like the create call. The old one stops working at once: everything we send from then on, including retries of deliveries that were already queued, is signed with the new secret. Update your endpoint straight away, or those callbacks will fail your check. If your endpoint rejects them with a non-2xx, we retry them, so nothing is lost as long as it has the new secret before the attempts run out. In the app, the same thing is the Regenerate secret button on the webhook's row (Configure a webhook).

Retries​

Anything in the 2xx range counts as delivered. Anything else, including a timeout, is retried: after 1 minute, then 5 minutes, then 30 minutes, then 2 hours. We wait 30 seconds for a response before giving up on an attempt, so answer quickly and do the work afterwards.

That is five attempts in total, spread over roughly two and a half hours. After the fifth failure the delivery is marked failed and we stop.

At that point the delivery cannot be replayed: POST /webhooks/{id}/deliveries/{delivery_id}/retry returns 400 once all five attempts are used, and so does retrying one that already succeeded. To catch up on what you missed, read the current state instead, with GET /api/public/v1/projects/{id} or the strings export above.