Skip to content

Email Templates

Helmdesk emails are Handlebars. There are three kinds of file, and knowing which is which is most of the learning curve.

KindWhat it isReferenced by
TemplateThe body of one specific email — the welcome mail, the receipt.You, by templateKey when sending.
LayoutThe branded shell every email shares — header, footer, styles. Injects the body via {{{content}}}.A template, by key.
PartialA reusable fragment — a button, an address block.Any template or layout, via {{> name}}.

Which project?

Every template belongs to one project. A key limited to one project implies it — send nothing extra. A key that reaches several projects (the kind you give a coding agent) names the project on each request with the Helmdesk-Project header (slug or id) or ?project=; without it the API answers 400 project.required with the projects it could have meant. Every object in a response carries projectId and projectSlug, so you can always tell what you are looking at.

curl
curl https://helmdesk.dev/api/v1/email-templates \
  -H "Authorization: Bearer sk_live_..." \
  -H "Helmdesk-Project: ledgerly"
SDK
// Pin the project once — or set HELMDESK_PROJECT and omit it.
const helmdesk = new HelmdeskClient({ apiKey, project: 'ledgerly' })
await helmdesk.emails.templates.list()

See Authentication for how keys and projects fit together.

Template format

A template is a plain .hbs file. Two optional metadata comments let the file carry its own subject line and layout, so the whole thing lives in your repo:

welcome.hbs
{{!-- subject: Welcome to {{companyName}} --}}
{{!-- layout: branded --}}

<h1>Hello {{name}}</h1>
<p>Welcome aboard!</p>
{{> footer}}

Both are read at import time. The subject becomes the template’s subject line; the layout key is resolved and linked to the template, which is what makes it show up in the dashboard’s Layout dropdown and get applied on every send.

Setting the layout

A layout has to exist before a template can point at it. Import the layout first, then the template — otherwise there is nothing to link to.

Two-step import
// 1. The shell
await helmdesk.emails.templates.import(
  '<div class="wrap"><img src="{{brandLogoUrl}}">{{{content}}}</div>',
  'branded.hbs',
  { type: 'layout' }
)

// 2. A template that uses it
const result = await helmdesk.emails.templates.import(
  '<h1>Hello {{name}}</h1>',
  'welcome.hbs',
  { type: 'template', subject: 'Welcome!', layout: 'branded' }
)

The layout option and the {{!-- layout: --}} comment do the same thing; the option wins if you use both. Check layoutLinked in the response to confirm it took:

Response — linked
{
  "id": "7d2e4c19-3f5a-4b8e-9c01-2a6d8f4b7e35",
  "key": "welcome",
  "name": "Welcome",
  "type": "template",
  "subject": "Welcome!",
  "variables": ["name"],
  "referencedPartials": [],
  "layoutKey": "branded",
  "layoutId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "layoutLinked": true,
  "warnings": []
}
Response — layout not found (import still succeeds)
{
  "key": "welcome",
  "type": "template",
  "layoutKey": "branded",
  "layoutId": null,
  "layoutLinked": false,
  "warnings": [
    "Layout \"branded\" does not exist in this project, so the template was imported without a layout. Import the layout first (type=layout), then re-import this template."
  ]
}

A missing layout never fails the import

An unresolved layout is a warning, not an error — the template saves and sends, just unwrapped. If your emails arrive without branding, check layoutLinked on the import response before looking anywhere else.

Changing or removing a layout

How the layout field behaves on re-import:

FieldTypeDescription
(omitted)—Keeps whatever layout the template already has. Re-importing a body with no layout comment will not wipe a layout you picked in the dashboard.
layout: 'other'stringRelinks the template to that layout.
layout: ''stringRemoves the layout. 'none' works too.

Import API

POST/api/v1/email-templates/importemails:manage

Upload a .hbs file as a template, layout, or partial (multipart form data). Re-importing an existing key updates it in place — templates get a new version.

Request

// Using the SDK
await helmdesk.emails.templates.import(
  fileContent,     // string or Blob
  'welcome.hbs',
  { type: 'template', layout: 'branded' }
)

// Using fetch
const form = new FormData()
form.append('file', file)
form.append('type', 'template')
form.append('layout', 'branded')

await fetch('https://helmdesk.dev/api/v1/email-templates/import', {
  method: 'POST',
  headers: { Authorization: 'Bearer sk_live_...' },
  body: form,
})

Response

{
  "id": "7d2e4c19-3f5a-4b8e-9c01-2a6d8f4b7e35",
  "key": "welcome",
  "name": "Welcome",
  "type": "template",
  "subject": "Welcome!",
  "variables": ["name", "activationUrl"],
  "referencedPartials": ["footer"],
  "layoutKey": "branded",
  "layoutId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "layoutLinked": true,
  "warnings": []
}
FieldTypeDescription
filerequiredfileThe .hbs file. Anything else is rejected with 422.
type'template' | 'layout' | 'partial'Defaults to template.
keystringDefaults to the filename without .hbs, lowercased.
namestringDisplay name. Defaults to a title-cased key.
subjectstringOverrides the {{!-- subject: --}} comment. Templates only.
layoutstringKey of an existing layout. Overrides the {{!-- layout: --}} comment. Templates only.

Deleting a template

Deletes the project template with that key in the request's environment; its versions go with it. Email Logs rows already rendered from it are kept, but sends that name the key afterwards fail — check your app's code for the key first. Shared @account/keys are refused with a 422: they are account-level and not a project's to delete.

DELETE/api/v1/email-templates/:keyemails:manage

Delete a project template by key. With a sandbox key (or Helmdesk-Environment: sandbox) only the sandbox copy is deleted; the live template is untouched.

Request

// SDK
await helmdesk.emails.templates.delete('welcome')

// curl
curl -X DELETE https://helmdesk.dev/api/v1/email-templates/welcome \
  -H "Authorization: Bearer sk_live_..." \
  -H "Helmdesk-Project: abc-salon"

Response

{
  "deleted": true,
  "id": "7d2e4c19-3f5a-4b8e-9c01-2a6d8f4b7e35",
  "key": "welcome",
  "projectId": "7f3a9c2e-4b1d-4e8f-a6c5-2d9b8e7f1a30",
  "projectSlug": "abc-salon"
}

Shared templates

Account-level templates, layouts, and partials are shared across every project. Reference them with the @account/ prefix. They are environment-less — there is no sandbox copy, and they are read-only while the dashboard is in sandbox mode. Shared templates are available on Growth and up.

POST/api/v1/account/email-templates/importemails:manage

Upload a .hbs file to the account scope (shared across all projects).

Request

// Upload a shared layout
await helmdesk.emails.templates.import(
  layoutHtml,
  'house-style.hbs',
  { type: 'layout', scope: 'account' }
)

// A shared template that uses it
await helmdesk.emails.templates.import(
  invoiceHtml,
  'invoice-paid.hbs',
  { type: 'template', scope: 'account', layout: 'house-style' }
)

// Send it from any project
await helmdesk.emails.send({
  templateKey: '@account/invoice-paid',
  to: { email: 'user@example.com' },
})

Response

{
  "id": "c9e1a740-62b3-4d5f-8a17-90b2c4d6e8f1",
  "key": "invoice-paid",
  "name": "Invoice Paid",
  "type": "template",
  "scope": "account",
  "subject": "Your invoice is paid",
  "referencedPartials": ["@account/footer"],
  "layoutKey": "house-style",
  "layoutLinked": true,
  "variables": ["invoiceNumber", "amount"],
  "warnings": []
}

Project templates cannot use shared layouts

A project template links to a layout by id, and layouts are per-project — so {{!-- layout: @account/house-style --}} on a project template returns a warning and no link. Either import the layout into the project, or move the template to the account scope where shared layouts resolve.

Resolution order

  • • Templates: the @account/ prefix picks the shared one explicitly; a bare key is the project’s own.
  • • Layouts: project layouts first, then account layouts with the same key as a fallback.
  • • Partials: project partials take priority; account partials fill in unmatched keys.

Sandbox

Project templates, layouts, and partials exist separately per environment. An sk_sandbox_ key imports into the sandbox plane, so you can iterate without touching production. Promote a finished sandbox template to live from the dashboard. See Sandbox.