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:

  1. An explicitly provided localeId - including an explicit null.
  2. The Course Template's defaultDeliveryLocale.
  3. 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.

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: TG9jYWxlOmZyLUZS

Locale exposes code and default for building a language picker, alongside name, fullName and supported.