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 oris_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
| Event | Sent when |
|---|---|
project.converted | The uploaded files have been converted and the project is ready to translate |
project.analysis_completed | Word count and analysis have finished |
project.quote_ready | A professional translation quote is ready for approval |
project.mt_completed | AI translation has finished for the project |
project.failed | The project failed. When the failure came from a processing task the payload adds error_message and file_name |
Orders
| Event | Sent when |
|---|---|
order.created | An 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.delivered | The order has been delivered |
order.cancelled | The 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.
| Event | Sent when |
|---|---|
strings.updated | Keys were added, updated or deleted |
strings.translated | AI drafts are ready to preview. The payload counts them per language |
strings.corrected | A translation was corrected through the /correct endpoint |
strings.approved | A 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.
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.*andorder.*carry the source the project or order was created with. Not who triggered the change. A project created in the app staysuifor 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 isapi; a reviewer approving a translation in the editor isui. So a webhook set to API only will not seestrings.approvedfrom 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.deliveredandorder.cancelledare sent when Taia's team updates the order. Those carry the sourceadminand 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>:
| Method | Path | What it does |
|---|---|---|
POST | /api/public/v1/webhooks | Create one. The response carries the secret, once. |
GET | /api/public/v1/webhooks | List 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-secret | Replace the secret. The response carries the new secret, once. |
DELETE | /api/public/v1/webhooks/{id} | Delete one. |
GET | /api/public/v1/webhooks/{id}/deliveries | Delivery history, with the response code we got. |
POST | /api/public/v1/webhooks/{id}/deliveries/{delivery_id}/retry | Replay 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:
| Header | What it tells you |
|---|---|
X-Webhook-Event | The event name, so you can route without parsing the body |
X-Webhook-Delivery-ID | The delivery's id. The same id on two requests means a retry, not a second event |
X-Webhook-Attempt | Which attempt this is, starting at 1 |
User-Agent | Always 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.