Skip to main content
Reach for the localization API when you manage translations in your own backend, a translation management system, or a script, rather than translating in Design Studio. It is a two-step flow: fetch your template’s content to get its element IDs, then set locale overrides against those IDs. Courier stores translations per element. This tutorial walks through both steps using a welcome email (nt_01ABC) translated to French. Set COURIER_API_KEY to a key from your API keys page before you start. For sending with locales, TMS webhooks, and the full endpoint reference, see Localization.

Fetch translatable content

Call GET /notifications/{id}/content to get every element you can translate:
The response is a tree of elements. channel and group elements hold their children in their own elements array:
Save the id of each element you want to translate, at whatever depth it sits. Add ?version=draft to read the draft instead of the published content. If your response comes back with blocks and channels rather than elements, that template predates the element model and the rest of this tutorial won’t apply to it.

Set a locale

One request per locale. Name each element by id and set the property that matches its type: content for text, title for meta.
Nested elements are addressed by their own id, flat in the elements array. You do not mirror the tree structure in the request. You get back 200 with the new checksums and the state the write landed on:
Adding German later is a second request to /locales/de_DE. Locales are independent, so it leaves French untouched. You can also send a partial update. The merge is one level deep: fields inside a locale merge, and each field’s value is then replaced wholesale. So sending only content for an element that also has an href translation leaves the href alone. The one exception is elements, which replaces that locale’s whole subtree, so send it complete. See how merging works. Keep variable expressions such as {{data.name}} byte-identical to the default content. Courier substitutes them at send time, and an altered expression will not resolve. Every override has to reference an element that already exists in the default content. One bad id fails the entire request with a 400 and writes nothing, so the valid overrides in the same array do not apply either. The error lists every bad id at once, so you can fix them all in one pass.

Draft vs published

Writes carry a state field instead of using a separate path: The request above omitted state, so it saved to the draft. Publish it:
Sending "state": "PUBLISHED" with the locale write does both steps at once. Note that PUBLISHED publishes the entire draft, not only your translation, so any other unpublished edits on that template go live with it.

Keep translations in sync

checksum is how you find translations that have gone stale. Store the element’s checksum when you translate it, then compare on the next fetch. If an element’s checksum changed, its default content changed and the translation behind it needs revisiting. When the default content is what changed, update the element itself. PUT /elements/{elementId} is a full replace, so pass the translations you want to keep in the same request:
Omit locales here and every existing translation on that element is wiped. This endpoint also does not validate its body: misspell locales as locale and you get a 200 while the translation silently never renders. When you are only changing translations, use PUT /locales/{localeId} instead, which merges and validates.
For how Courier picks a locale at send time, including what happens when a translation is incomplete, see Elemental locales.