Translations
Course Templates and Learning Paths can hold their text content in more than one language. A translation is a sparse per-locale overlay: it stores only the fields translated into that Locale, and every field it does not carry falls back to the entity's own content.
Concepts
| Concept | Field | Meaning |
|---|---|---|
| Primary Locale | CourseTemplate.locale, LearningPath.locale |
The language the entity's own, untranslated content is written in. Required before translations can be managed. |
| Delivery Locales | CourseTemplate.deliveryLocales |
The Locales the course can be delivered in. Independent of its translations - a course may be deliverable in a language it has no translated content for. |
| Default Delivery Locale | CourseTemplate.defaultDeliveryLocale |
The Locale used when an Event is created without one. Always one of deliveryLocales. |
| Translation | CourseTemplate.translations, LearningPath.translations |
The per-Locale overlay. Never includes the primary Locale. |
Learning Paths have no delivery Locales - those exist to drive Event creation.
Null, empty string and fallback
null- the field is not translated in this Locale. Consumers fall back to the base value on the entity.""- a deliberate blank in this Locale. It does not fall back.
The distinction is only reachable through the API. The Administrate interface
sends null for any field left blank.
Permissions
| Operation | Permission |
|---|---|
Read CourseTemplate.translations |
COURSE_TEMPLATE_VIEW |
courseTemplate.upsertTranslation, courseTemplate.deleteTranslation |
COURSE_TEMPLATE_EDIT |
Read LearningPath.translations |
LEARNING_PATH_VIEW |
learningPaths.upsertTranslation, learningPaths.deleteTranslation |
LEARNING_PATH_EDIT |
The permission is checked before anything else, so a caller without it gets a permission error rather than a validation error revealing whether the entity exists.
Course Template Locales
localeId, deliveryLocaleIds and defaultDeliveryLocaleId are all optional
on CourseTemplateCreateInput and
CourseTemplateUpdateInput.
Example
mutation SetCourseTemplateLocales {
courseTemplate {
update(
courseTemplateId: "Q291cnNlVGVtcGxhdGU6MQ=="
input: {
localeId: "TG9jYWxlOmVuLUdC"
deliveryLocaleIds: ["TG9jYWxlOmVuLUdC", "TG9jYWxlOmZyLUZS"]
defaultDeliveryLocaleId: "TG9jYWxlOmVuLUdC"
}
) {
courseTemplate {
locale { name }
deliveryLocales { name }
defaultDeliveryLocale { name }
}
errors { label value message }
}
}
}defaultDeliveryLocaleId must be one of deliveryLocaleIds.
Read a translation
translations returns one entry per Locale the entity has been translated into.
Pass locale to return just that Locale's overlay, or an empty list when it is
not translated into it.
translatedFields lists the GraphQL field names that are non-null in this
Locale, which is the completeness signal to drive a "missing translations"
indicator from.
Example
query TranslatedCourseTemplate {
courseTemplates(filters: [
{field: id, operation: eq, value: "Q291cnNlVGVtcGxhdGU6MQ=="}
]) {
edges {
node {
title
customFieldValues { definitionKey value }
translations(locale: "TG9jYWxlOmZyLUZS") {
locale { name code }
title
courseText1
translatedFields
customFieldValues { definitionKey value }
}
}
}
}
}{
"data": {
"courseTemplates": {
"edges": [
{
"node": {
"title": "Advanced Welding",
"customFieldValues": [
{ "definitionKey": "Q3VzdG9tRmllbGREZWZpbml0aW9uOjE2", "value": "Workshop" }
],
"translations": [
{
"locale": { "name": "French", "code": "fr-FR" },
"title": "Soudage avance",
"courseText1": null,
"translatedFields": ["title"],
"customFieldValues": [
{ "definitionKey": "Q3VzdG9tRmllbGREZWZpbml0aW9uOjE2", "value": "Atelier" }
]
}
]
}
}
]
}
}
}courseText1 is null above, so a consumer renders the base
courseTemplates.edges[].node.courseText1 for that field.
For the full type see the CourseTemplateTranslation API Reference.
Note the attribute mapping: courseText2 on the API is the Course Template's
introduction, and relatedCoursesText is its related courses text.
Write a translation
upsertTranslation creates or fully replaces one Locale's overlay. Fields
omitted from the input are stored as null and fall back to the base content -
this is a replace, not a patch, so always send the complete set of values you
want that Locale to have.
Example
mutation UpsertCourseTemplateTranslation {
courseTemplate {
upsertTranslation(input: {
courseTemplateId: "Q291cnNlVGVtcGxhdGU6MQ=="
localeId: "TG9jYWxlOmZyLUZS"
title: "Soudage avance"
courseText1: "<p>Un cours pratique de soudage.</p>"
lmsSummary: "Soudage avance pour techniciens experimentes"
customFieldValues: [
{ definitionKey: "Q3VzdG9tRmllbGREZWZpbml0aW9uOjE2", value: "Atelier" }
]
}) {
translation {
locale { name }
title
translatedFields
}
errors { label value message }
}
}
}{
"data": {
"courseTemplate": {
"upsertTranslation": {
"translation": {
"locale": { "name": "French" },
"title": "Soudage avance",
"translatedFields": ["title", "courseText1", "lmsSummary"]
},
"errors": []
}
}
}
}Rich text values are sanitised on write, matching the base Course Template write
path. title is not sanitised, because it is single-line text.
For the full range of available parameters see the CourseTemplateUpsertTranslationInput API Reference.
Validation
upsertTranslation returns a FieldError rather than raising when:
| Message | Cause |
|---|---|
Course Template not found |
No Course Template for courseTemplateId |
Course Template must have a primary language before translations can be managed |
The template has no locale |
Cannot manage a translation in the Course Template's primary language |
localeId is the template's own Locale |
Custom Field Definition does not exist or does not apply to this Course Template |
definitionKey is unknown or applies elsewhere |
Custom Field has no base value on the Course Template to translate |
Nothing to translate for that definition |
This Custom Field is a built-in course description field; translate it with the courseText fields on this mutation, not customFieldValues |
A course description field passed through customFieldValues |
A custom field must also be flagged translatable and be a text-shaped type, and its value must pass the definition's own validator.
Delete a translation
Example
mutation DeleteCourseTemplateTranslation {
courseTemplate {
deleteTranslation(input: {
courseTemplateId: "Q291cnNlVGVtcGxhdGU6MQ=="
localeId: "TG9jYWxlOmZyLUZS"
}) {
translation { id }
errors { label value message }
}
}
}translation is null after a delete. The delete is a hard delete, and the
Locale's translated custom field values are removed with it.
Translatable Custom Fields
A Custom Field Definition opts in through isTranslatable, available on
CustomFieldDefinitionInput and
exposed on the definition types. It defaults to false.
CourseTemplateTranslation.customFieldValues mirrors the merged shape of
CourseTemplate.customFieldValues, so translated values line up with base
values by definitionKey.
Learning Paths
Learning Paths expose the same surface, under the learningPaths mutation
namespace, with name and description as the translatable fields.
Example
mutation UpsertLearningPathTranslation {
learningPaths {
upsertTranslation(input: {
learningPathId: "TGVhcm5pbmdQYXRoOjE="
localeId: "TG9jYWxlOmRlLURF"
name: "Grundlagen der Sicherheit"
description: "Ein Einfuhrungspfad fur neue Techniker."
customFieldValues: [
{ definitionKey: "Q3VzdG9tRmllbGREZWZpbml0aW9uOjI0", value: "Einfuhrung" }
]
}) {
translation {
locale { name }
name
translatedFields
}
errors { label value message }
}
}
}learningPaths.deleteTranslation takes learningPathId and localeId and
behaves as the Course Template equivalent. Learning Path translations are stored
verbatim rather than sanitised, because neither base field is rich text.
Creating an Event in a delivery Locale
localeId on the Event create inputs decides the language the Event is
delivered in. The Event's inherited title and custom field values are
snapshotted from the Course Template's translation for that Locale, falling back
per field to the base content.
Locale resolution:
- An explicitly provided
localeId- including an explicitnull. - The Course Template's
defaultDeliveryLocale. - The Course Template's own
locale.
An explicitly chosen Locale must be one of the template's deliveryLocales. A
template with no delivery Locales configured imposes no restriction, so
existing integrations are unaffected.
Example
mutation CreateFrenchEvent {
event {
createClassroom(input: {
courseTemplateId: "Q291cnNlVGVtcGxhdGU6MQ=="
localeId: "TG9jYWxlOmZyLUZS"
eventType: public
locationId: "TG9jYXRpb246ODQ="
taxTypeId: "VGF4VHlwZToz"
timeZoneName: "Europe/Paris"
classroomStartDateTime: "2026-09-10T10:00:00"
}) {
event {
title
locale { name }
customFieldValues { definitionKey value }
}
errors { label value message }
}
}
}{
"data": {
"event": {
"createClassroom": {
"event": {
"title": "Soudage avance",
"locale": { "name": "French" },
"customFieldValues": [
{ "definitionKey": "Q3VzdG9tRmllbGREZWZpbml0aW9uOjE2", "value": "Atelier" },
{ "definitionKey": "Q3VzdG9tRmllbGREZWZpbml0aW9uOjY3", "value": null }
]
},
"errors": []
}
}
}
}An unsupported Locale returns Provided locale is not supported; a Locale
outside the template's delivery Locales returns
Locale is not one of the Course Template's delivery languages against the
localeId label.
Re-applying a Course Template to an Event re-inherits in the Event's language: a cascaded title is translated, a cascaded Locale follows the delivery-default chain, and custom field values copy once the Locale resolves. Duplicated Events keep their Locale.
WebLink and LMS content Locale
The WebLink API accepts an opt-in weblink-content-locale request header
carrying a Locale id. When present, catalogue and course queries overlay the
translated fields onto the authored content, falling back per field to the
Locale the course was authored in. Requests that do not send the header get
exactly the response they got before.
POST /graphql HTTP/1.1
weblink-content-locale: TG9jYWxlOmZyLUZSLocale exposes code and default for building a language picker, alongside
name, fullName and supported.