Guides
Templates
Create versioned Ray templates with Mustache-style variables, sections and per-channel escaping, then publish and send them by templateId.
A template is reusable message content for one channel shape. You create it, publish it, and send it with templateId on POST /send. Every request below goes to https://ray-api.gege.mn with Authorization: Bearer $RAY_API_KEY; creating and changing templates needs the write scope.
A template has:
- a
channelKindthat fixes its content shape (and which channels can send it), contentwith{{variable}}placeholders,logTitleandlogDescription: text used for the customer feed and delivery logs, rendered with the same params,- versions: edits go to a draft, and sends always use the published version.
Template kinds
channelKind |
Channels | content fields |
|---|---|---|
email_html |
ses_email, smtp_email |
subject (1 to 998 chars), bodyHtml (string), bodyText (string) |
fcm_basic |
fcm_push |
title (1 to 200), body (1 to 2000), imageUrl (URL, optional), data (object of string to string, optional) |
slack_text |
slack_webhook |
text (1 to 40,000, Slack mrkdwn) |
discord_text |
discord_webhook |
content (1 to 2000, Discord markdown) |
telegram_text |
telegram_bot |
text (1 to 4096, Telegram HTML), disableLinkPreview (boolean, optional) |
sms_text |
twilio_sms, sendsms_mn |
text (1 to 1600, plain text). Long messages are split into several SMS: Twilio does it itself; a sendsms_mn channel splits into SMS of at most 159 characters of plain Latin (GSM-7) text, or 69 if the text contains any Cyrillic or other non-GSM character. More than 1600 characters after params are filled in is rejected with 400 on Twilio. |
webhook_json |
generic_webhook |
title (1 to 200), body (1 to 4000), data (object of string to string, optional) |
Sending a template through a channel of another kind fails with 400: template kind 'slack_text' is not compatible with channel 'ses_email'.
Templates created in the dashboard's drag-and-drop email designer are dashboard-only. The API creates and edits raw templates only, and rejects content with "source": "designed" or a mailyJson field.
Create a template
POST /templates
| Field | Type | Notes |
|---|---|---|
name |
string, 1 to 100 | Unique within the workspace, including archived templates. |
folder |
string, max 200 | Optional grouping label. Default "". |
channelKind |
enum | One of the kinds above. Cannot change later. |
content |
object | Shape for the kind. |
logTitle |
string, 1 to 500 | Required. |
logDescription |
string, 1 to 2000 | Required. |
paramOverrides |
object | Optional. { "<param>": { "optional"?: boolean, "description"?: string } }. See Required params. |
publish |
boolean | Default false. true publishes the first version immediately. |
curl -X POST https://ray-api.gege.mn/templates \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Verification code",
"folder": "auth",
"channelKind": "email_html",
"content": {
"subject": "Your code is {{code}}",
"bodyHtml": "<p>Hi {{name}}, your code is <b>{{code}}</b>.</p>",
"bodyText": "Hi {{name}}, your code is {{code}}."
},
"logTitle": "Verification code sent",
"logDescription": "We emailed {{name}} a sign-in code.",
"publish": true
}'
201 Created:
{ "id": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "requiredParams": ["code", "name"] }
const res = await fetch("https://ray-api.gege.mn/templates", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RAY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Order shipped push",
channelKind: "fcm_basic",
content: {
title: "Order {{orderId}} shipped",
body: "Arriving {{eta}}.",
data: { screen: "order", orderId: "{{orderId}}" },
},
logTitle: "Order shipped",
logDescription: "Order {{orderId}} is arriving {{eta}}.",
publish: true,
}),
});
if (res.status !== 201) throw new Error(`template create failed: ${await res.text()}`);
const { id, requiredParams } = (await res.json()) as { id: string; requiredParams: string[] };
A name that is already taken returns 409 conflict (the message suggests unarchiving if the existing template is archived).
Template syntax
Templates use a subset of Mustache.
| Syntax | Meaning |
|---|---|
{{name}} |
Interpolate a scalar (string, number, boolean), escaped for the field. null renders as empty. |
{{user.firstName}} |
Dotted path into a nested object. |
{{#items}}...{{/items}} |
Section. Repeats once per element if items is an array, renders once if it is a truthy object or scalar, renders nothing if it is absent, null, false, "" or an empty array. Inside, names resolve against the current item first, then outer values. |
{{.}} |
The current item inside a section (for arrays of scalars). |
{{^items}}...{{/items}} |
Inverted section. Renders only when items is absent, null, false, "" or an empty array. |
Not supported: unescaped output ({{{x}}}, {{& x}}), partials ({{> x}}), comments and helpers. Unrecognized {{...}} text is left as literal text. A template with an unclosed or mismatched section is rejected with 400 when created or updated.
A digest email with a list and an empty-state fallback:
{
"subject": "{{count}} items need your review",
"bodyHtml": "{{#boards}}<h3>{{name}}</h3><ul>{{#questions}}<li>{{text}}</li>{{/questions}}</ul>{{/boards}}{{^boards}}<p>Nothing pending.</p>{{/boards}}",
"bodyText": "{{#boards}}{{name}}: {{#questions}}- {{text}} {{/questions}}{{/boards}}{{^boards}}Nothing pending.{{/boards}}"
}
Params for that template on POST /send:
{
"count": 2,
"boards": [
{ "name": "Asian League", "questions": [{ "text": "Who tops the group?" }, { "text": "Top scorer?" }] }
]
}
Interpolating an object or array directly ({{boards}}) fails with 400: cannot interpolate non-scalar parameter 'boards'; use a section {{#boards}}...{{/boards}}.
Required params
When you create or update a template, Ray detects its required params across content, logTitle and logDescription and returns them as requiredParams.
- Only top-level interpolations are required. Section names, and names used only inside a section, are not.
- A dotted name requires its first segment:
{{user.firstName}}requiresuser.
At send time, a missing required param fails the whole request with 400: missing required params: code, name.
paramOverrides marks a detected param as optional ("optional": true) so the up-front check skips it, and can hold a description for your team. An optional param that is used as a top-level {{name}} still needs a value when rendered: pass null to render it empty, or wrap it in a section ({{#promo}}...{{promo}}...{{/promo}}) so it renders only when present. Otherwise the send fails with 400: missing required parameter: <name>.
Escaping
Parameter values are escaped per field so they can't break out of their context. Text you write in the template itself is never escaped.
| Kind | Field | Escaping applied to param values |
|---|---|---|
email_html |
subject |
None, but a value containing a line break fails the send with 400 (header injection guard) |
email_html |
bodyHtml |
HTML (&, <, >, ", ') |
email_html |
bodyText |
None |
fcm_basic |
all fields | None |
slack_text |
text |
&, <, > become entities, which blocks injected links and mentions. *, _, ~ are not escaped. |
discord_text |
content |
Backslash before \ ` * _ ~ | > # -. Mentions are disabled at send time. |
telegram_text |
text |
HTML (&, <, >, ", ') |
webhook_json |
all fields | None (values are JSON-encoded in the payload) |
| all | logTitle, logDescription |
None. Escape these yourself when rendering the feed in HTML. |
There is no raw-output opt-out. Structure comes from the template and data stays escaped.
Versions and publishing
A template has at most one draft version and at most one published version at a time. Version numbers start at 1.
POST /templatescreates version 1 as a draft (published immediately ifpublish: true).PATCH /templates/{id}writes the draft. If a draft exists it is overwritten; if not (the latest version is published), a new draft with the next version number is created.POST /templates/{id}/publishpromotes the draft to published. Sends switch to it immediately.
POST /send always renders the published version. A template that has never been published cannot be sent (404 template not found or not published). Every send stores its own rendered content, so publishing a new version never changes what past sends show.
Update the draft
PATCH /templates/{id} takes the same body as create. channelKind must equal the template's kind (400 channelKind cannot be changed after creation). name and folder are required by the schema but are not changed by this call. Set publish: true to publish in the same call.
curl -X PATCH https://ray-api.gege.mn/templates/1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Verification code",
"channelKind": "email_html",
"content": {
"subject": "{{code}} is your sign-in code",
"bodyHtml": "<p>Hi {{name}}, use <b>{{code}}</b> within 10 minutes.</p>",
"bodyText": "Hi {{name}}, use {{code}} within 10 minutes."
},
"logTitle": "Verification code sent",
"logDescription": "We emailed {{name}} a sign-in code."
}'
{ "id": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "draftVersionId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "requiredParams": ["code", "name"] }
Publish
curl -X POST https://ray-api.gege.mn/templates/1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a/publish \
-H "Authorization: Bearer $RAY_API_KEY"
{ "id": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "versionId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "versionNumber": 2 }
Publishing with no draft returns 400 no draft to publish.
Read templates
GET /templates lists templates. Query parameters: folder (exact match) and includeArchived=true.
{
"templates": [
{
"id": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"name": "Verification code",
"folder": "auth",
"channelKind": "email_html",
"sourceMode": "raw",
"publishedVersionId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
"archivedAt": null,
"createdAt": "2026-09-01T08:00:00.000Z",
"updatedAt": "2026-09-14T09:00:00.000Z"
}
]
}
GET /templates/{id} returns the same fields plus published and draft, each null or an object with versionId, versionNumber, content, logTitle, logDescription, requiredParams, paramOverrides, and publishedAt (published) or updatedAt (draft).
Archive and unarchive
POST /templates/{id}/archivereturns{ "id": "...", "archived": true }. Archived templates cannot be sent, edited or published.POST /templates/{id}/unarchivereturns{ "id": "...", "archived": false }.
Test a template
POST /templates/{id}/test-send delivers the published version to a real recipient without touching feeds or webhooks. See Test sends.
Templates or inline content
POST /send also accepts inline content in the same shape as a template's content, rendered with the same syntax and escaping. Use templates when content is reused, reviewed or edited outside your code; use inline content for one-off or fully code-generated messages. Inline sends have no paramOverrides: every top-level variable must have a value.
Errors
| Status | error |
message |
|---|---|---|
400 |
validation_error |
content: <field>: <reason>, channelKind: ..., unclosed section: {{#items}}, mismatched section: expected {{/a}} but found {{/b}}, template is archived, channelKind cannot be changed after creation, no draft to publish, designed-mode templates can only be created via the dashboard; the API path is raw-only, write scope required |
404 |
not_found |
template not found, template not found or already archived, template not found or not archived |
409 |
conflict |
a template named '<name>' already exists in this tenant (...) |