> ## Documentation Index
> Fetch the complete documentation index at: https://www.courier.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message`, the default import of the v7 Node SDK. Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Template translations

> Add locales to a template and Courier picks the right one per recipient at send time.

export const Endpoint = ({method, path, name, href, children, bare}) => {
  const verb = String(method || "").toUpperCase();
  const title = verb + " " + path;
  const label = children || name || path;
  if (bare) {
    return href ? <a href={href}><code>{title}</code></a> : <code>{title}</code>;
  }
  if (!href) {
    return <span className="cx-endpoint" data-method={verb} title={title}>
        <span className="cx-endpoint-label">{label}</span>
        <span className="cx-endpoint-method">{verb}</span>
      </span>;
  }
  return <a className="cx-endpoint" data-method={verb} href={href} title={title}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">{verb}</span>
    </a>;
};

export const Doc = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="doc" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">DOC</span>
    </a>;
};

Add a locale to a Template and Courier picks it per recipient at send time.

## How it works

When `message.to` includes a `locale`, Courier replaces default property values with that locale's translations. Elements with no matching locale definition keep their defaults.

The locale interface:

```ts theme={null}
interface Locales {
  [locale: string]: {
    content?: string;      // text, action, quote, html
    title?: string;        // meta
    href?: string;         // action, image
    src?: string;          // image
    raw?: Record<string, unknown>; // channel (provider-native overrides)
    elements?: Element[];  // shape varies by element type, see above
  };
}
```

### Text node resolution

Text elements accept two content formats: `content` (a plain or markdown string) and `elements` (a structured array of inline nodes like string, link, and img). Locales handle every combination:

| Root node has          | Locale provides        | Behavior                                                                        |
| ---------------------- | ---------------------- | ------------------------------------------------------------------------------- |
| `content`              | `content`              | Replaces the content string                                                     |
| `elements`             | `elements`             | Replaces the elements array                                                     |
| `elements`             | `content`              | Wraps the content string into a single-element array for backward compatibility |
| `content` + `elements` | `content` + `elements` | Uses `elements` (structured format takes precedence)                            |
| `content` + `elements` | `content` only         | Wraps the content string into a single-element array                            |
| `content` + `elements` | `elements` only        | Uses the locale's elements array                                                |

<Note>
  If a locale translation is missing for an element, Courier falls back to the default property value. Notifications still render when translations are incomplete.
</Note>

<Warning>
  When a text node uses the `elements` format and a locale provides only a `content` string, Courier wraps the string into a single-element array (`[{ type: "string", content: "..." }]`). Rendering survives, but any inline formatting (bold, italic, links) in the original elements is lost. Provide `elements` in your locale translations to keep formatting.
</Warning>

## Why use locales

* **Single template, multiple languages**: One structure and logic, with translated text per locale
* **Automatic selection**: Courier uses the recipient's locale from their profile or the `message.to.locale` field
* **Fallback support**: If a translation is missing, Courier uses the default content

## Supported elements and properties

Which properties accept a locale override depends on the element type:

* **`content`**: Text content in `text`, `action`, `quote`, and `html` elements
* **`title`**: Title in `meta` elements (email subject lines, push notification titles)
* **`href`**: URLs in `action` and `image` elements
* **`src`**: Image source URLs in `image` elements
* **`raw`**: Provider-native channel overrides in `channel` elements, such as a translated `subject` or an entire translated `html` body
* **`elements`**: Nested elements. The accepted shape varies by type:
  * inline nodes (string, link, img) in `text` and `list-item`
  * full nodes in `channel`, `group`, and `column`
  * column nodes in `columns`
  * list-item nodes in `list`

`divider`, `jsonnet`, `partial`, and `comment` elements reject any locale override. Sending a property that an element type does not support returns a `400` naming the field.

## Add translations

<Steps>
  <Step title="Add a locale in the designer">
    Open the template, add a locale (like `fr` or `pt-BR`), and enter the translated content for each element. Courier stores per-locale content on the template, so the structure stays shared and only the text differs.
  </Step>

  <Step title="Or set it via the API">
    Write a locale's content with <Endpoint method="PUT" path="/notifications/{id}/locales/{localeId}" name="Replace Notification Locale" href="/docs/api-reference/templates/replace-notification-locale" /> or the `put-locale` CLI verb. Use this to keep translations in your own system and sync them in.
  </Step>

  <Step title="Translate with AI">
    Courier's AI translation generates a locale's content from your base content, which you then review and edit. This is a console feature. Run it per template when you add or update a locale.

    AI translation needs the **Business** or **Enterprise** plan. Each translation costs **2.5 AI credits** plus token overages, drawn from the same balance as <Doc href="/docs/journeys/nodes/ai#billing">AI journey nodes</Doc>. With no credits the console reports that translation requires them.
  </Step>

  <Step title="Send to a localized recipient">
    Set a test user's `profile.locale` to a locale you localized and send the template. Confirm the localized content renders in <Doc href="/docs/monitor/overview">message logs</Doc>. Then send to a user without that locale and confirm the base content renders.
  </Step>
</Steps>

## Workspace translation strings

Workspace translations hold short strings reused across templates, like button labels and common phrases. They are keyed by locale and rendered with the `{{t}}` Handlebars helper. Manage them with <Endpoint method="PUT" path="/translations/{domain}/{locale}" name="Update Translations by locale" href="/docs/api-reference/translations/update-translations-by-locale" /> and reference a string with `{{t "welcome_headline"}}`. Use this for a shared glossary, and per-locale template content for the body of a notification.

Three details decide how you integrate it:

* **The payload is a `.po` file**, not JSON. That is the gettext format most translation tooling already exports, so a vendor's output usually needs no conversion.
* **`domain` only accepts `default` today.** It exists for future namespacing, and any other value fails.
* **Read them back with <Endpoint method="GET" path="/translations/{domain}/{locale}" name="Get a Translation" href="/docs/api-reference/translations/get-a-translation" />**, which returns the stored `.po` content. Useful for diffing what is live against what your translation system holds.

<Note>
  Translations are per workspace, not per tenant. A <Doc href="/docs/tenants/overview">tenant</Doc> cannot override a string, so a customer who needs different wording needs a different template rather than a different translation.
</Note>

## Basic example

Localize a text element:

<CodeGroup>
  ```javascript Node.js lines highlight={19} theme={null}
  import Courier from "@trycourier/courier";

  const courier = new Courier({
    apiKey: process.env.COURIER_API_KEY,
  });

  const { requestId } = await courier.send.message({
    message: {
      to: {
        email: "sarah@acme-corp.com",
        locale: "fr",
      },
      content: {
        version: "2022-01-01",
        elements: [
          {
            type: "text",
            content: "Hello",
            locales: {
              fr: {
                content: "Bonjour",
              },
              es: {
                content: "Hola",
              },
            },
          },
        ],
      },
    },
  });

  console.log(`Sent with request ID: ${requestId}`);
  ```

  ```python Python lines highlight={18} theme={null}
  import os
  from courier import Courier

  client = Courier(api_key=os.environ["COURIER_API_KEY"])

  response = client.send.message(
      message={
          "to": {
              "email": "sarah@acme-corp.com",
              "locale": "fr",
          },
          "content": {
              "version": "2022-01-01",
              "elements": [
                  {
                      "type": "text",
                      "content": "Hello",
                      "locales": {
                          "fr": {"content": "Bonjour"},
                          "es": {"content": "Hola"},
                      },
                  }
              ],
          },
      }
  )

  print(f"Sent with request ID: {response.request_id}")
  ```

  ```bash cURL highlight={16} wrap theme={null}
  curl -X POST https://api.courier.com/send \
    -H "Authorization: Bearer $COURIER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "message": {
        "to": {
          "email": "sarah@acme-corp.com",
          "locale": "fr"
        },
        "content": {
          "version": "2022-01-01",
          "elements": [
            {
              "type": "text",
              "content": "Hello",
              "locales": {
                "fr": {
                  "content": "Bonjour"
                },
                "es": {
                  "content": "Hola"
                }
              }
            }
          ]
        }
      }
    }'
  ```

  ```ruby Ruby lines highlight={17} theme={null}
  require "courier"

  courier = Courier::Client.new(api_key: ENV["COURIER_API_KEY"])

  response = courier.send_.message(
    message: {
      to: {
        email: "sarah@acme-corp.com",
        locale: "fr"
      },
      content: {
        version: "2022-01-01",
        elements: [
          {
            type: "text",
            content: "Hello",
            locales: {
              fr: { content: "Bonjour" },
              es: { content: "Hola" }
            }
          }
        ]
      }
    }
  )

  puts("Sent with request ID: #{response.request_id}")
  ```

  ```go Go lines highlight={8} theme={null}
  // Elemental nodes carry no typed content fields, so pass the document as raw JSON.
  content := param.Override[shared.ElementalContentParam](json.RawMessage(`{
    "version": "2022-01-01",
    "elements": [
      {
        "type": "text",
        "content": "Hello",
        "locales": {
          "fr": { "content": "Bonjour" },
          "es": { "content": "Hola" }
        }
      }
    ]
  }`))

  response, err := client.Send.Message(context.TODO(), courier.SendMessageParams{
  	Message: courier.SendMessageParamsMessage{
  		To: courier.SendMessageParamsMessageToUnion{
  			OfUserRecipient: &shared.UserRecipientParam{
  				Email:  courier.String("sarah@acme-corp.com"),
  				Locale: courier.String("fr"),
  			},
  		},
  		Content: courier.SendMessageParamsMessageContentUnion{OfElementalContent: &content},
  	},
  })
  if err != nil {
  	panic(err.Error())
  }

  fmt.Printf("Sent with request ID: %s\n", response.RequestID)
  ```

  ```java Java lines highlight={14} theme={null}
  SendMessageParams params = SendMessageParams.builder()
      .message(SendMessageParams.Message.builder()
          .to(UserRecipient.builder()
              .email("sarah@acme-corp.com")
              .locale("fr")
              .build())
          // Elemental nodes carry no typed content fields, so pass the document as raw JSON.
          .content(JsonValue.from(java.util.Map.of(
              "version", "2022-01-01",
              "elements", java.util.List.of(
                java.util.Map.of(
                  "type", "text",
                  "content", "Hello",
                  "locales", java.util.Map.of(
                    "fr", java.util.Map.of("content", "Bonjour"),
                    "es", java.util.Map.of("content", "Hola")))))))
          .build())
      .build();
  SendMessageResponse response = client.send().message(params);
  ```

  ```php PHP lines highlight={13} theme={null}
  $response = $client->send->message(
    message: [
      'to' => [
        'email' => 'sarah@acme-corp.com',
        'locale' => 'fr',
      ],
      'content' => [
        'version' => '2022-01-01',
        'elements' => [
          [
            'type' => 'text',
            'content' => 'Hello',
            'locales' => [
              'fr' => ['content' => 'Bonjour'],
              'es' => ['content' => 'Hola'],
            ],
          ],
        ],
      ],
    ],
  );
  ```

  ```csharp C# lines highlight={15} theme={null}
  SendMessageParams parameters = new()
  {
      Message = new()
      {
          To = new UserRecipient { Email = "sarah@acme-corp.com", Locale = "fr" },
          // Elemental nodes expose no typed content fields, so build the document from raw JSON.
          Content = ElementalContent.FromRawUnchecked(
              JsonSerializer.Deserialize<Dictionary<string, JsonElement>>("""
              {
                "version": "2022-01-01",
                "elements": [
                  {
                    "type": "text",
                    "content": "Hello",
                    "locales": {
                      "fr": { "content": "Bonjour" },
                      "es": { "content": "Hola" }
                    }
                  }
                ]
              }
              """)),
      },
  };

  var response = await client.Send.Message(parameters);
  ```

  ```bash CLI highlight={4} wrap theme={null}
  courier send message \
    --api-key "$COURIER_API_KEY" \
    --message.to '{"email": "sarah@acme-corp.com", "locale": "fr"}' \
    --message.content '{"version": "2022-01-01", "elements": [{"type": "text", "content": "Hello", "locales": {"fr": {"content": "Bonjour"}, "es": {"content": "Hola"}}}]}'
  ```

  ```text MCP theme={null}
  With Courier MCP, send my greeting to sarah@acme-corp.com with locale fr, and localize it into French and Spanish.
  ```
</CodeGroup>

When the recipient's locale is `"fr"`, `"Hello"` becomes `"Bonjour"`. When it is `"es"`, it becomes `"Hola"`. Any other locale, or none, renders the default `"Hello"`.

## Localizing multiple elements

Localize several element types in one template:

<CodeGroup>
  ```javascript Node.js lines highlight={22,34,47} theme={null}
  import Courier from "@trycourier/courier";

  const courier = new Courier({
    apiKey: process.env.COURIER_API_KEY,
  });

  const { requestId } = await courier.send.message({
    message: {
      to: {
        email: "sarah@acme-corp.com",
        locale: "es",
      },
      data: {
        user_name: "María",
      },
      content: {
        version: "2022-01-01",
        elements: [
          {
            type: "meta",
            title: "Welcome to our platform",
            locales: {
              es: {
                title: "Bienvenido a nuestra plataforma",
              },
              fr: {
                title: "Bienvenue sur notre plateforme",
              },
            },
          },
          {
            type: "text",
            content: "Thanks for signing up, {{user_name}}!",
            locales: {
              es: {
                content: "¡Gracias por registrarte, {{user_name}}!",
              },
              fr: {
                content: "Merci de vous être inscrit, {{user_name}} !",
              },
            },
          },
          {
            type: "action",
            content: "Get Started",
            href: "https://app.example.com/dashboard",
            locales: {
              es: {
                content: "Comenzar",
                href: "https://app.example.com/es/dashboard",
              },
              fr: {
                content: "Commencer",
                href: "https://app.example.com/fr/dashboard",
              },
            },
          },
        ],
      },
    },
  });

  console.log(`Sent with request ID: ${requestId}`);
  ```

  ```python Python lines highlight={21,29,38} theme={null}
  import os
  from courier import Courier

  client = Courier(api_key=os.environ["COURIER_API_KEY"])

  response = client.send.message(
      message={
          "to": {
              "email": "sarah@acme-corp.com",
              "locale": "es",
          },
          "data": {
              "user_name": "María",
          },
          "content": {
              "version": "2022-01-01",
              "elements": [
                  {
                      "type": "meta",
                      "title": "Welcome to our platform",
                      "locales": {
                          "es": {"title": "Bienvenido a nuestra plataforma"},
                          "fr": {"title": "Bienvenue sur notre plateforme"},
                      },
                  },
                  {
                      "type": "text",
                      "content": "Thanks for signing up, {{user_name}}!",
                      "locales": {
                          "es": {"content": "¡Gracias por registrarte, {{user_name}}!"},
                          "fr": {"content": "Merci de vous être inscrit, {{user_name}} !"},
                      },
                  },
                  {
                      "type": "action",
                      "content": "Get Started",
                      "href": "https://app.example.com/dashboard",
                      "locales": {
                          "es": {
                              "content": "Comenzar",
                              "href": "https://app.example.com/es/dashboard",
                          },
                          "fr": {
                              "content": "Commencer",
                              "href": "https://app.example.com/fr/dashboard",
                          },
                      },
                  },
              ],
          },
      }
  )

  print(f"Sent with request ID: {response.request_id}")
  ```

  ```bash cURL highlight={19,31,44} wrap theme={null}
  curl -X POST https://api.courier.com/send \
    -H "Authorization: Bearer $COURIER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "message": {
        "to": {
          "email": "sarah@acme-corp.com",
          "locale": "es"
        },
        "data": {
          "user_name": "María"
        },
        "content": {
          "version": "2022-01-01",
          "elements": [
            {
              "type": "meta",
              "title": "Welcome to our platform",
              "locales": {
                "es": {
                  "title": "Bienvenido a nuestra plataforma"
                },
                "fr": {
                  "title": "Bienvenue sur notre plateforme"
                }
              }
            },
            {
              "type": "text",
              "content": "Thanks for signing up, {{user_name}}!",
              "locales": {
                "es": {
                  "content": "¡Gracias por registrarte, {{user_name}}!"
                },
                "fr": {
                  "content": "Merci de vous être inscrit, {{user_name}} !"
                }
              }
            },
            {
              "type": "action",
              "content": "Get Started",
              "href": "https://app.example.com/dashboard",
              "locales": {
                "es": {
                  "content": "Comenzar",
                  "href": "https://app.example.com/es/dashboard"
                },
                "fr": {
                  "content": "Commencer",
                  "href": "https://app.example.com/fr/dashboard"
                }
              }
            }
          ]
        }
      }
    }'
  ```

  ```ruby Ruby lines highlight={20,28,37} theme={null}
  require "courier"

  courier = Courier::Client.new(api_key: ENV["COURIER_API_KEY"])

  response = courier.send_.message(
    message: {
      to: {
        email: "sarah@acme-corp.com",
        locale: "es"
      },
      data: {
        user_name: "María"
      },
      content: {
        version: "2022-01-01",
        elements: [
          {
            type: "meta",
            title: "Welcome to our platform",
            locales: {
              es: { title: "Bienvenido a nuestra plataforma" },
              fr: { title: "Bienvenue sur notre plateforme" }
            }
          },
          {
            type: "text",
            content: "Thanks for signing up, {{user_name}}!",
            locales: {
              es: { content: "¡Gracias por registrarte, {{user_name}}!" },
              fr: { content: "Merci de vous être inscrit, {{user_name}} !" }
            }
          },
          {
            type: "action",
            content: "Get Started",
            href: "https://app.example.com/dashboard",
            locales: {
              es: {
                content: "Comenzar",
                href: "https://app.example.com/es/dashboard"
              },
              fr: {
                content: "Commencer",
                href: "https://app.example.com/fr/dashboard"
              }
            }
          }
        ]
      }
    }
  )

  puts("Sent with request ID: #{response.request_id}")
  ```

  ```go Go lines highlight={8,16,25} expandable theme={null}
  // Elemental nodes carry no typed content fields, so pass the document as raw JSON.
  content := param.Override[shared.ElementalContentParam](json.RawMessage(`{
    "version": "2022-01-01",
    "elements": [
      {
        "type": "meta",
        "title": "Welcome to our platform",
        "locales": {
          "es": { "title": "Bienvenido a nuestra plataforma" },
          "fr": { "title": "Bienvenue sur notre plateforme" }
        }
      },
      {
        "type": "text",
        "content": "Thanks for signing up, {{user_name}}!",
        "locales": {
          "es": { "content": "¡Gracias por registrarte, {{user_name}}!" },
          "fr": { "content": "Merci de vous être inscrit, {{user_name}} !" }
        }
      },
      {
        "type": "action",
        "content": "Get Started",
        "href": "https://app.example.com/dashboard",
        "locales": {
          "es": {
            "content": "Comenzar",
            "href": "https://app.example.com/es/dashboard"
          },
          "fr": {
            "content": "Commencer",
            "href": "https://app.example.com/fr/dashboard"
          }
        }
      }
    ]
  }`))

  response, err := client.Send.Message(context.TODO(), courier.SendMessageParams{
  	Message: courier.SendMessageParamsMessage{
  		To: courier.SendMessageParamsMessageToUnion{
  			OfUserRecipient: &shared.UserRecipientParam{
  				Email:  courier.String("sarah@acme-corp.com"),
  				Locale: courier.String("es"),
  			},
  		},
  		Data: map[string]any{
  			"user_name": "María",
  		},
  		Content: courier.SendMessageParamsMessageContentUnion{OfElementalContent: &content},
  	},
  })
  if err != nil {
  	panic(err.Error())
  }

  fmt.Printf("Sent with request ID: %s\n", response.RequestID)
  ```

  ```java Java lines highlight={15,21,28} expandable theme={null}
  SendMessageParams params = SendMessageParams.builder()
      .message(SendMessageParams.Message.builder()
          .to(UserRecipient.builder()
              .email("sarah@acme-corp.com")
              .locale("es")
              .build())
          .data(JsonValue.from(java.util.Map.of("user_name", "María")))
          // Elemental nodes carry no typed content fields, so pass the document as raw JSON.
          .content(JsonValue.from(java.util.Map.of(
              "version", "2022-01-01",
              "elements", java.util.List.of(
                java.util.Map.of(
                  "type", "meta",
                  "title", "Welcome to our platform",
                  "locales", java.util.Map.of(
                    "es", java.util.Map.of("title", "Bienvenido a nuestra plataforma"),
                    "fr", java.util.Map.of("title", "Bienvenue sur notre plateforme"))),
                java.util.Map.of(
                  "type", "text",
                  "content", "Thanks for signing up, {{user_name}}!",
                  "locales", java.util.Map.of(
                    "es", java.util.Map.of("content", "¡Gracias por registrarte, {{user_name}}!"),
                    "fr", java.util.Map.of("content", "Merci de vous être inscrit, {{user_name}} !"))),
                java.util.Map.of(
                  "type", "action",
                  "content", "Get Started",
                  "href", "https://app.example.com/dashboard",
                  "locales", java.util.Map.of(
                    "es", java.util.Map.of(
                      "content", "Comenzar",
                      "href", "https://app.example.com/es/dashboard"),
                    "fr", java.util.Map.of(
                      "content", "Commencer",
                      "href", "https://app.example.com/fr/dashboard")))))))
          .build())
      .build();
  SendMessageResponse response = client.send().message(params);
  ```

  ```php PHP lines highlight={16,24,33} expandable theme={null}
  $response = $client->send->message(
    message: [
      'to' => [
        'email' => 'sarah@acme-corp.com',
        'locale' => 'es',
      ],
      'data' => [
        'user_name' => 'María',
      ],
      'content' => [
        'version' => '2022-01-01',
        'elements' => [
          [
            'type' => 'meta',
            'title' => 'Welcome to our platform',
            'locales' => [
              'es' => ['title' => 'Bienvenido a nuestra plataforma'],
              'fr' => ['title' => 'Bienvenue sur notre plateforme'],
            ],
          ],
          [
            'type' => 'text',
            'content' => 'Thanks for signing up, {{user_name}}!',
            'locales' => [
              'es' => ['content' => '¡Gracias por registrarte, {{user_name}}!'],
              'fr' => ['content' => 'Merci de vous être inscrit, {{user_name}} !'],
            ],
          ],
          [
            'type' => 'action',
            'content' => 'Get Started',
            'href' => 'https://app.example.com/dashboard',
            'locales' => [
              'es' => [
                'content' => 'Comenzar',
                'href' => 'https://app.example.com/es/dashboard',
              ],
              'fr' => [
                'content' => 'Commencer',
                'href' => 'https://app.example.com/fr/dashboard',
              ],
            ],
          ],
        ],
      ],
    ],
  );
  ```

  ```csharp C# lines highlight={19,27,36} expandable theme={null}
  SendMessageParams parameters = new()
  {
      Message = new()
      {
          To = new UserRecipient { Email = "sarah@acme-corp.com", Locale = "es" },
          Data = new Dictionary<string, JsonElement>()
          {
              { "user_name", JsonSerializer.SerializeToElement("María") },
          },
          // Elemental nodes expose no typed content fields, so build the document from raw JSON.
          Content = ElementalContent.FromRawUnchecked(
              JsonSerializer.Deserialize<Dictionary<string, JsonElement>>("""
              {
                "version": "2022-01-01",
                "elements": [
                  {
                    "type": "meta",
                    "title": "Welcome to our platform",
                    "locales": {
                      "es": { "title": "Bienvenido a nuestra plataforma" },
                      "fr": { "title": "Bienvenue sur notre plateforme" }
                    }
                  },
                  {
                    "type": "text",
                    "content": "Thanks for signing up, {{user_name}}!",
                    "locales": {
                      "es": { "content": "¡Gracias por registrarte, {{user_name}}!" },
                      "fr": { "content": "Merci de vous être inscrit, {{user_name}} !" }
                    }
                  },
                  {
                    "type": "action",
                    "content": "Get Started",
                    "href": "https://app.example.com/dashboard",
                    "locales": {
                      "es": {
                        "content": "Comenzar",
                        "href": "https://app.example.com/es/dashboard"
                      },
                      "fr": {
                        "content": "Commencer",
                        "href": "https://app.example.com/fr/dashboard"
                      }
                    }
                  }
                ]
              }
              """)),
      },
  };

  var response = await client.Send.Message(parameters);
  ```

  ```bash CLI highlight={5} wrap theme={null}
  courier send message \
    --api-key "$COURIER_API_KEY" \
    --message.to '{"email": "sarah@acme-corp.com", "locale": "es"}' \
    --message.data '{"user_name": "María"}' \
    --message.content '{"version": "2022-01-01", "elements": [{"type": "meta", "title": "Welcome to our platform", "locales": {"es": {"title": "Bienvenido a nuestra plataforma"}, "fr": {"title": "Bienvenue sur notre plateforme"}}}, {"type": "text", "content": "Thanks for signing up, {{user_name}}!", "locales": {"es": {"content": "¡Gracias por registrarte, {{user_name}}!"}, "fr": {"content": "Merci de vous être inscrit, {{user_name}} !"}}}, {"type": "action", "content": "Get Started", "href": "https://app.example.com/dashboard", "locales": {"es": {"content": "Comenzar", "href": "https://app.example.com/es/dashboard"}, "fr": {"content": "Commencer", "href": "https://app.example.com/fr/dashboard"}}}]}'
  ```

  ```text MCP theme={null}
  With Courier MCP, send a localized email to sarah@acme-corp.com in Spanish, translating the title, body, and button.
  ```
</CodeGroup>

The example covers:

* **Meta element**: Localizing the email subject line (`title` property)
* **Text element**: Localizing body content with Handlebars variables
* **Action element**: Localizing both button text (`content`) and URL (`href`)

## Structured elements

When a text node uses the `elements` array (inline string, link, and img nodes), provide locale translations as `elements` arrays too. That preserves bold, italic, and inline links across languages.

```json theme={null}
{
  "type": "text",
  "elements": [
    { "type": "string", "content": "Your order " },
    { "type": "string", "content": "#{{order.id}}", "bold": true },
    { "type": "string", "content": " has been " },
    { "type": "string", "content": "confirmed", "bold": true },
    { "type": "string", "content": "." }
  ],
  "locales": {
    "fr": {
      "elements": [
        { "type": "string", "content": "Votre commande " },
        { "type": "string", "content": "#{{order.id}}", "bold": true },
        { "type": "string", "content": " a été " },
        { "type": "string", "content": "confirmée", "bold": true },
        { "type": "string", "content": "." }
      ]
    },
    "ja": {
      "elements": [
        { "type": "string", "content": "ご注文 " },
        { "type": "string", "content": "#{{order.id}}", "bold": true },
        { "type": "string", "content": " が" },
        { "type": "string", "content": "確認", "bold": true },
        { "type": "string", "content": "されました。" }
      ]
    }
  }
}
```

### Mixed locale formats

You can mix `content` and `elements` across locales in the same text node. Use it when some translations need formatting and others can be plain strings:

```json theme={null}
{
  "type": "text",
  "elements": [
    { "type": "string", "content": "Need help? " },
    { "type": "link", "content": "Contact support", "href": "https://support.example.com" },
    { "type": "string", "content": "." }
  ],
  "locales": {
    "fr": {
      "content": "Besoin d'aide ? [Contacter le support](https://support.example.com/fr)."
    },
    "es": {
      "elements": [
        { "type": "string", "content": "¿Necesita ayuda? " },
        { "type": "link", "content": "Contactar soporte", "href": "https://support.example.com/es" },
        { "type": "string", "content": "." }
      ]
    }
  }
}
```

The French locale uses a `content` string, which Courier wraps into a single text element at render time. The Spanish locale keeps the structured elements with an inline link.

### When Both `content` and `elements` Are Present

If a locale entry includes both `content` and `elements`, only `elements` is used. Choose one format per locale entry. See the [resolution table](#text-node-resolution) for all combinations.

## Locale sources

Courier resolves the recipient's locale in this order:

1. **`message.to.locale`**: Set in the `to` object of the send request (highest priority)
2. **User profile locale**: Stored in the user's profile via the <Doc href="/docs/recipients/overview">Profiles API</Doc>
3. **Default fallback**: If no locale is found, the default content is used

<Note>
  `message.to.locale` takes precedence over every other source. Courier merges it into the profile object during processing, so it overrides any locale stored in the user's profile.
</Note>

Two places to set it:

<CodeGroup>
  ```javascript Node.js lines highlight={1,6,14} theme={null}
  // Option 1: In message.to.locale (highest priority)
  await courier.send.message({
    message: {
      to: {
        email: "sarah@acme-corp.com",
        locale: "fr",
      },
      content: { ... },
    },
  });

  // Option 2: On the user's stored profile, so every send picks it up
  await courier.profiles.create("user_123", {
    profile: { locale: "fr" },
  });

  await courier.send.message({
    message: {
      to: {
        user_id: "user_123",
      },
      content: { ... },
    },
  });
  ```

  ```python Python lines highlight={1,6,13} theme={null}
  # Option 1: In message.to.locale (highest priority)
  client.send.message(
      message={
          "to": {
              "email": "sarah@acme-corp.com",
              "locale": "fr",
          },
          "content": { ... },
      }
  )

  # Option 2: On the user's stored profile, so every send picks it up
  client.profiles.create("user_123", profile={"locale": "fr"})

  client.send.message(
      message={
          "to": {
              "user_id": "user_123",
          },
          "content": { ... },
      }
  )
  ```

  ```bash cURL highlight={1,7,16} theme={null}
  # Option 1: In message.to.locale (highest priority)
  curl -X POST https://api.courier.com/send \
    -H "Authorization: Bearer $COURIER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "message": {
        "to": { "email": "sarah@acme-corp.com", "locale": "fr" },
        "content": { ... }
      }
    }'

  # Option 2: On the user's stored profile, so every send picks it up
  curl -X POST https://api.courier.com/profiles/user_123 \
    -H "Authorization: Bearer $COURIER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "profile": { "locale": "fr" } }'

  curl -X POST https://api.courier.com/send \
    -H "Authorization: Bearer $COURIER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "message": {
        "to": { "user_id": "user_123" },
        "content": { ... }
      }
    }'
  ```

  ```ruby Ruby lines highlight={1,6,13} theme={null}
  # Option 1: In message.to.locale (highest priority)
  courier.send_.message(
    message: {
      to: {
        email: "sarah@acme-corp.com",
        locale: "fr"
      },
      content: { ... }
    }
  )

  # Option 2: On the user's stored profile, so every send picks it up
  courier.profiles.create("user_123", profile: { locale: "fr" })

  courier.send_.message(
    message: {
      to: {
        user_id: "user_123"
      },
      content: { ... }
    }
  )
  ```

  ```go Go lines highlight={3,18} theme={null}
  // `content` is the Elemental document, built as raw JSON in the examples above.

  // Option 1: In message.to.locale (highest priority)
  client.Send.Message(context.TODO(), courier.SendMessageParams{
  	Message: courier.SendMessageParamsMessage{
  		To: courier.SendMessageParamsMessageToUnion{
  			OfUserRecipient: &shared.UserRecipientParam{
  				Email:  courier.String("sarah@acme-corp.com"),
  				Locale: courier.String("fr"),
  			},
  		},
  		Content: courier.SendMessageParamsMessageContentUnion{OfElementalContent: &content},
  	},
  })

  // Option 2: On the user's stored profile, so every send picks it up
  client.Profiles.New(context.TODO(), "user_123", courier.ProfileNewParams{
  	Profile: map[string]any{"locale": "fr"},
  })

  client.Send.Message(context.TODO(), courier.SendMessageParams{
  	Message: courier.SendMessageParamsMessage{
  		To: courier.SendMessageParamsMessageToUnion{
  			OfUserRecipient: &shared.UserRecipientParam{
  				UserID: courier.String("user_123"),
  			},
  		},
  		Content: courier.SendMessageParamsMessageContentUnion{OfElementalContent: &content},
  	},
  })
  ```

  ```java Java lines highlight={3,8,18} theme={null}
  // `content` is the Elemental document, built as raw JSON in the examples above.

  // Option 1: In message.to.locale (highest priority)
  client.send().message(SendMessageParams.builder()
      .message(SendMessageParams.Message.builder()
          .to(UserRecipient.builder()
              .email("sarah@acme-corp.com")
              .locale("fr")
              .build())
          .content(content)
          .build())
      .build());

  // Option 2: On the user's stored profile, so every send picks it up
  client.profiles().create(ProfileCreateParams.builder()
      .userId("user_123")
      .profile(ProfileCreateParams.Profile.builder()
          .putAdditionalProperty("locale", JsonValue.from("fr"))
          .build())
      .build());

  client.send().message(SendMessageParams.builder()
      .message(SendMessageParams.Message.builder()
          .to(UserRecipient.builder().userId("user_123").build())
          .content(content)
          .build())
      .build());
  ```

  ```php PHP lines highlight={3,6,12} theme={null}
  // $content is the Elemental document, built as an array in the examples above.

  // Option 1: In message.to.locale (highest priority)
  $client->send->message(
    message: [
      'to' => ['email' => 'sarah@acme-corp.com', 'locale' => 'fr'],
      'content' => $content,
    ],
  );

  // Option 2: On the user's stored profile, so every send picks it up
  $client->profiles->create('user_123', profile: ['locale' => 'fr']);

  $client->send->message(
    message: [
      'to' => ['userID' => 'user_123'],
      'content' => $content,
    ],
  );
  ```

  ```csharp C# lines highlight={3,19} theme={null}
  // `content` is the Elemental document, built from raw JSON in the examples above.

  // Option 1: In message.to.locale (highest priority)
  await client.Send.Message(new SendMessageParams
  {
      Message = new()
      {
          To = new UserRecipient { Email = "sarah@acme-corp.com", Locale = "fr" },
          Content = content,
      },
  });

  // Option 2: On the user's stored profile, so every send picks it up
  await client.Profiles.Create(new ProfileCreateParams
  {
      UserID = "user_123",
      Profile = new Dictionary<string, JsonElement>()
      {
          { "locale", JsonSerializer.SerializeToElement("fr") },
      },
  });

  await client.Send.Message(new SendMessageParams
  {
      Message = new()
      {
          To = new UserRecipient { UserID = "user_123" },
          Content = content,
      },
  });
  ```

  ```bash CLI highlight={3,6,13} wrap theme={null}
  # CONTENT holds the Elemental document, as in the examples above.

  # Option 1: In message.to.locale (highest priority)
  courier send message \
    --api-key "$COURIER_API_KEY" \
    --message.to '{"email": "sarah@acme-corp.com", "locale": "fr"}' \
    --message.content "$CONTENT"

  # Option 2: On the user's stored profile, so every send picks it up
  courier profiles create \
    --api-key "$COURIER_API_KEY" \
    --user-id user_123 \
    --profile '{"locale": "fr"}'

  courier send message \
    --api-key "$COURIER_API_KEY" \
    --message.to '{"user_id": "user_123"}' \
    --message.content "$CONTENT"
  ```

  ```text MCP theme={null}
  With Courier MCP, send my greeting to sarah@acme-corp.com and set the locale to fr on the message." Use the Courier MCP tool "create_or_merge_user" with a prompt like: "Store the locale fr on user_123's profile so their sends localize.
  ```
</CodeGroup>

<Tip>
  Set the locale in the user's profile (via the Profiles API) and every notification for that user is localized, with no locale in the send request. `message.to.locale` still overrides it.
</Tip>

## Best practices

### Use consistent locale codes

Use standard locale codes (`en-US`, `es-ES`, `fr-FR`) or simple language codes (`en`, `es`, `fr`) consistently across your templates. Courier supports any locale string format, but consistency makes maintenance easier.

### Provide default content

Always provide default content for each element. Notifications then render even when:

* A user's locale isn't supported
* A translation is missing
* The locale field is omitted

### Localize URLs when needed

For action buttons and images, localize the `href` and `src` properties to point at localized versions of your website or app:

```json theme={null}
{
  "type": "action",
  "content": "View Dashboard",
  "href": "https://app.example.com/dashboard",
  "locales": {
    "es": {
      "content": "Ver Panel",
      "href": "https://app.example.com/es/dashboard"
    }
  }
}
```

### Combine with channel customization

Combine locales with <Doc href="/docs/design/elemental/control-flow">channel-specific customization</Doc> for localized, per-channel content:

```json theme={null}
{
  "type": "channel",
  "channel": "email",
  "elements": [
    {
      "type": "text",
      "content": "Check your email for details",
      "locales": {
        "es": {
          "content": "Revisa tu correo para más detalles"
        }
      }
    }
  ]
}
```

### Test all locales

Before deploying, test your templates with every supported locale and check:

* All translations are present
* Handlebars variables work correctly in all languages
* URLs and links are properly localized
* Text fits within UI constraints (button sizes, email widths, etc.)
