Ray
PricingDocsSign inStart free
Browse the docs

Getting started

Ray documentationAuthenticationUsing Ray with AI coding agentsMCP serverTypeScript SDK

Guides

Sending notificationsIdempotencyTemplatesScheduling and test sendsStatus and feedsClick trackingDelivery webhooks

Channels

Email with Amazon SESEmail with SMTPPush notifications with Firebase Cloud MessagingSlack notificationsDiscord notificationsTelegram notificationsSMS with TwilioSMS with sendsms.mnHTTPS webhook channel

Reference

Rate limits and quotasErrors

Getting started

Ray documentationAuthenticationUsing Ray with AI coding agentsMCP serverTypeScript SDK

Guides

Sending notificationsIdempotencyTemplatesScheduling and test sendsStatus and feedsClick trackingDelivery webhooks

Channels

Email with Amazon SESEmail with SMTPPush notifications with Firebase Cloud MessagingSlack notificationsDiscord notificationsTelegram notificationsSMS with TwilioSMS with sendsms.mnHTTPS webhook channel

Reference

Rate limits and quotasErrors

Guides

Templates

Create versioned Ray templates with Mustache-style variables, sections and per-channel escaping, then publish and send them by templateId.

View as Markdownllms.txtAPI reference

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 channelKind that fixes its content shape (and which channels can send it),
  • content with {{variable}} placeholders,
  • logTitle and logDescription: 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}} requires user.

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.

  1. POST /templates creates version 1 as a draft (published immediately if publish: true).
  2. 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.
  3. POST /templates/{id}/publish promotes 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}/archive returns { "id": "...", "archived": true }. Archived templates cannot be sent, edited or published.
  • POST /templates/{id}/unarchive returns { "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 (...)
PreviousIdempotencyNextScheduling and test sends

On this page

  • Template kinds
  • Create a template
  • Template syntax
  • Required params
  • Escaping
  • Versions and publishing
  • Update the draft
  • Publish
  • Read templates
  • Archive and unarchive
  • Test a template
  • Templates or inline content
  • Errors
Ray

Notification delivery for teams that already have providers.

Product

PricingDocsAPI referenceMCP serverTypeScript SDKAgent skillsllms.txt

Legal

TermsPrivacyRefunds

Contact

[email protected]

© 2026 L’Atelier Gege LLC