Notification Templates
A notification template defines the content ThunderID sends to a user: the subject and body of an email, or the message body of an SMS. Templates are YAML files loaded from disk at startup, and each one is selected by a scenario (what the message is for) and a type (the channel it goes out on).
Templates support placeholders. At send time, ThunderID substitutes values gathered during the flow, such as a one-time password, an invitation link, or the application name, into the body before handing the result to the email or SMS provider.
Prerequisites
- ThunderID is running. See Get ThunderID.
- Filesystem access to the deployment's config directory, since templates are files rather than Console resources.
- For delivery, a configured SMTP server or SMS provider.
Scenarios and Channels
Each template declares one scenario and one type. ThunderID looks up templates by that pair, so a scenario can have at most one email template and one SMS template.
| Scenario | Sent when |
|---|---|
USER_INVITE | An administrator invites a user to create an account. |
SELF_REGISTRATION | A user starts self-registration and must confirm their address. |
PASSWORD_RECOVERY | A user requests a password reset link. |
MAGIC_LINK | A user signs in with a magic link instead of a password. |
OTP | A one-time password is issued for verification or sign-in. |
CIBA_NOTIFICATION | A backchannel authentication request needs the user's approval. |
The two supported values for type are email and sms.
Not every scenario ships with a template on both channels. Out of the box, ThunderID provides email and SMS templates for USER_INVITE, SELF_REGISTRATION, OTP, and CIBA_NOTIFICATION, and email only for MAGIC_LINK and PASSWORD_RECOVERY. You can add the missing channel by creating a file for it.
If a flow asks for a scenario and type combination that has no template, the send fails. Adding a template file is what makes that combination available.
Where Templates Live
Templates are loaded from the templates directory under the server's config resources directory:
<THUNDERID_HOME>/config/resources/templates/
In a container image, that path is /app/config/resources/templates.
Each .yaml file in the directory is one template. The filename does not matter; the id, scenario, and type fields inside the file do.
Templates are read once, at server startup. Editing a file has no effect on a running server. Restart ThunderID after any change.
Validation also happens at load time, and a bad template stops the server from starting. A template fails validation when id, scenario, or body is missing, when scenario is not one of the values listed above, or when subject is missing on a template whose type is not sms.
Template Schema
id: "sms-otp"
displayName: "SMS OTP Verification"
scenario: "OTP"
type: "sms"
contentType: "text/plain"
body: "Your verification code is: {{ctx(otpCode)}}. It expires in {{ctx(expiryMinutes)}} minutes."
| Field | Required | Description |
|---|---|---|
id | Yes | Unique identifier for the template. Must be unique across every file in the directory. |
scenario | Yes | Which notification this template is used for. See Scenarios and Channels. |
body | Yes | The message body. May contain {{ctx(key)}} placeholders. |
subject | For email | Email subject line. May contain placeholders. Not used for SMS. |
type | No | email or sms. Determines the channel this template serves. |
displayName | No | Human-readable name, used in logs and tooling. |
contentType | No | text/html or text/plain. Controls escaping and how the body is sent. See Content Type and Escaping. |
Injecting Runtime Values
Placeholders use the form {{ctx(key)}}. At render time, each placeholder is replaced with the matching value from the data collected during the flow.
body: "Your verification code is: {{ctx(otpCode)}}. It expires in {{ctx(expiryMinutes)}} minutes."
The syntax is deliberately narrow, and it is worth knowing its limits before you write a template:
- Keys are case sensitive.
{{ctx(otpCode)}}resolves;{{ctx(otpcode)}}does not. - Only letters, digits, and underscores are recognized inside the parentheses. A key containing a dot, dash, or space is not matched at all, so the text is left alone.
- There are no filters, defaults, conditionals, or loops. This is string substitution, not a template language. Anything conditional has to be handled by using a different template.
- Unresolved placeholders are passed through literally. If a key has no value at render time, the recipient sees the raw text
{{ctx(otpCode)}}in their message. ThunderID does not fail the send or substitute an empty string, so a typo in a key name reaches the user.
That last point is the reason the tables below matter: a placeholder only resolves if something earlier in the flow produced the value.
Values Available in Every Email Notification
These are attached by the notification step itself, so they resolve wherever a flow sends an email.
| Key | Value |
|---|---|
appName | Name of the application the flow is running for. |
applicationId | ID of that application. |
userID | ID of the user, once an earlier step has identified them. |
senderId | ID of the notification sender, when the step is configured with one. |
idpId | ID of the identity provider, when the step is configured with one. |
On this release, SMS templates receive only appName and the values contributed by an upstream executor. The keys in the table above, along with any attribute an attribute collection step resolved, reach email templates only. Using them in an SMS body emits the placeholder literally.
Values Contributed by an Earlier Step
Everything else comes from a step that runs before the notification step. If that step is not in the flow, the value does not exist and its placeholder is emitted literally. These values reach both email and SMS templates.
| Value is produced by | Keys it makes available |
|---|---|
| The OTP executor, in generate mode | otpCode, expiryMinutes |
| The invite executor | inviteLink, appName |
| The magic link executor | magicLink, expiryMinutes, appName |
expiryMinutes is a bare number, not a phrase, so write the unit yourself: expires in {{ctx(expiryMinutes)}} minutes.
Two further values come from outside the executors, and both arrive by a path that email templates can read but SMS templates cannot:
bindingMessage, set by a CIBA authentication request.- Any user attribute an attribute collection step resolved, such as
email,mobile_number, orusername.
Values a user types into a prompt are not automatically available to templates. Only attributes that an attribute collection step resolved onto the flow are.
What Each Scenario Can Use
Combining the two tables above gives the practical answer per scenario. Every email scenario also has access to the common values in Values Available in Every Email Notification.
| Scenario | Placeholders you can use | Because |
|---|---|---|
USER_INVITE | inviteLink, appName | Driven by the invite executor. |
SELF_REGISTRATION | inviteLink, appName | Driven by the invite executor. |
PASSWORD_RECOVERY | inviteLink, appName | Driven by the invite executor. |
CIBA_NOTIFICATION | inviteLink, appName, and bindingMessage on email only | Invite executor, plus the binding message from the CIBA request. |
MAGIC_LINK | magicLink, expiryMinutes, appName | Driven by the magic link executor. |
OTP | otpCode, expiryMinutes | Driven by the OTP executor. |
The recurring surprise here is inviteLink. Password recovery, self-registration, and CIBA approval all deliver their link through the same invite executor, so the key is inviteLink in all of them. There is no resetLink or approvalLink.
The SMS template shipped for CIBA_NOTIFICATION contains {{ctx(bindingMessage)}}, but that key does not resolve on the SMS channel in this release, so it is delivered as literal text. Remove it from the SMS body, or send CIBA approvals over email, until you upgrade.
The Standalone SMS OTP Endpoint
POST /auth/otp/sms/send renders the OTP SMS template outside of any flow. Because no flow is running, there is no application, no user, and no identity provider to draw from, and only two values are supplied:
otpCodeexpiryMinutes, derived from the server's configured OTP validity period rather than from a flow step
Any other placeholder in the OTP SMS template, including appName, is emitted literally on this path. If you use this endpoint, keep that template to those two keys.
Content Type and Escaping
contentType controls how substituted values are treated:
text/html: values substituted into the body are HTML-escaped before insertion. A user attribute containing<or&is rendered as text rather than markup, which prevents a stored value from injecting HTML into the message.text/plain, or any other value: substituted values are inserted as-is, with no escaping.
Email templates are also sent as HTML only when contentType is exactly text/html. Any other value, including an omitted contentType, is delivered as plain text.
The subject field is never HTML-escaped, on any content type. Avoid interpolating user-controlled attributes into a subject line.
SMS Length
An SMS segment holds 160 characters. When a rendered SMS body exceeds that, ThunderID logs a warning and still sends the message; the provider then splits it into multiple segments, which most providers bill separately.
Write SMS bodies against the rendered length, not the template length. A body that looks short can grow well past 160 characters once a long inviteLink or a long appName is substituted in.
Next Steps
- Configure an SMS Provider: Connect the gateway that delivers your SMS templates
- Configure SMTP Server: Connect the mail server that delivers your email templates
- Build a Flow: Add the notification step that renders a template