Skip to content

Emails

Send transactional emails using Handlebars templates with layouts and partials. Delivery status, the rendered HTML, and every provider event land in Email Logs.

Mail goes out through the provider you connect: Brevo, Resend, SendGrid (an API key with the Mail Send permission), Amazon SES (an IAM access key allowed ses:SendEmail, its region, and a sender identity verified in that region) or any SMTPserver. Set it in a project's Email config, or once under Admin Settings → Email providers to share it across projects. Credentials are encrypted at rest and never shown again.

Which project?

Every email send 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/emails/send \
  -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.send({ templateKey: 'welcome', to: { email: 'dana@example.com' } })

See Authentication for how keys and projects fit together.

POST/api/v1/emails/sendemails:send

Send a transactional email using a registered template. Supports idempotency keys to prevent duplicate sends, and an optional sendAt to schedule the send up to 30 days ahead.

Request

const result = await helmdesk.emails.send({
  templateKey: 'welcome',
  to: { email: 'user@example.com', name: 'Jane' },
  variables: { activationUrl: 'https://...' },
}, { idempotencyKey: 'signup-jane-2024' })

// Schedule instead of sending now:
const scheduled = await helmdesk.emails.send({
  templateKey: 'trial-expiring',
  to: { email: 'user@example.com' },
  sendAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // in 3 days
})
// scheduled.status === 'scheduled', scheduled.scheduledAt set

Response

{
  "id": "d41f9a6b-88c5-4e2a-b7d0-1e5c3f9a2b64",
  "status": "queued",
  "to": "user@example.com",
  "subject": "Welcome to Acme",
  "deduplicated": false,
  "blocked": false,
  "blockReason": null,
  "scheduledAt": null,
  "createdAt": "2026-07-21T09:15:00.000Z"
}
POST/api/v1/emails/batchemails:send

Send up to 100 transactional emails in one request. Each item is delivered independently — one failed item does not fail the batch. Idempotency is a per-item field (idempotencyKey) rather than a header. Requires the emails:send scope and Starter and up.

Request

const result = await helmdesk.emails.sendBatch({
  emails: [
    {
      templateKey: 'welcome',
      to: { email: 'a@example.com', name: 'Ann' },
      variables: { firstName: 'Ann' },
      idempotencyKey: 'welcome-a-2026',
    },
    {
      templateKey: 'welcome',
      to: { email: 'b@example.com' },
      variables: { firstName: 'Bo' },
    },
  ],
})
// result.total / result.sent / result.blocked / result.failed
// result.results[i] -> { index, id, status, to, ... } on success,
//                      { index, to, error } on failure

Response

{
  "total": 2,
  "sent": 1,
  "blocked": 0,
  "failed": 1,
  "results": [
    {
      "index": 0,
      "id": "d41f9a6b-88c5-4e2a-b7d0-1e5c3f9a2b64",
      "status": "queued",
      "to": "a@example.com",
      "subject": "Welcome",
      "deduplicated": false,
      "blocked": false,
      "blockReason": null,
      "createdAt": "2026-07-21T09:15:00.000Z"
    },
    { "index": 1, "to": "b@example.com", "error": "Template \"welcome\" not found" }
  ]
}
POST/api/v1/emails/previewemails:send

Preview a rendered email template without sending. Uses sample data if no variables are provided.

Request

const { subject, html } = await helmdesk.emails.preview({
  templateKey: 'welcome',
  variables: { name: 'Test User' },
})

Response

{
  "subject": "Welcome, Test User",
  "html": "<html><body><h1>Welcome, Test User</h1>…</body></html>"
}
GET/api/v1/email-templatesemails:send

List the project's email templates (with current-version subject and detected variables) plus your account's shared templates (keys already @account/-prefixed). This is how integrations discover valid templateKey values for send and preview. Requires the emails:send scope.

Request

const { templates, accountTemplates } = await helmdesk.emails.templates.list()
// templates[0] -> { key: 'welcome', subject: 'Welcome!', variables: [...] }

Response

{
  "templates": [
    {
      "key": "welcome",
      "name": "Welcome",
      "description": "Subject: Welcome to {{brandName}}",
      "subject": "Welcome to {{brandName}}",
      "variables": ["name", "activationUrl"],
      "updatedAt": "2026-07-20T16:31:12.004Z"
    }
  ],
  "accountTemplates": [
    {
      "key": "@account/invoice-paid",
      "name": "Invoice Paid",
      "description": null,
      "subject": "Your invoice is paid",
      "updatedAt": "2026-06-02T10:00:00.000Z"
    }
  ]
}
GET/api/v1/email-templates/:key/schemaemails:manage

The variables a template expects, detected from its current version. Built-in branding variables (brandName, brandLogoUrl, supportEmail, …) are excluded — Helmdesk fills those in at send time.

Request

const schema = await helmdesk.emails.getTemplateSchema('welcome')
// schema.variables -> ['name', 'activationUrl']

Response

{
  "key": "welcome",
  "name": "Welcome",
  "subject": "Welcome to {{brandName}}",
  "variables": ["name", "activationUrl"]
}

Email log

“Did the customer get the email?” is the top support question, so the log is readable from the API. Both endpoints need the emails:readscope — a read scope of its own, because a key that can send should not get every recipient and subject for free. The log is per environment: an sk_sandbox_ key (or a live key with Helmdesk-Environment: sandbox) reads the sandbox log.

GET/api/v1/emailsemails:read

The project's email sends, newest first. Filters: to (recipient substring), status (comma-separated), kind (template|custom), templateKey, dateFrom, dateTo, page, limit (max 100). Rows never include the rendered HTML.

Request

curl "https://helmdesk.dev/api/v1/emails?to=dana@&status=bounced,blocked" \
  -H "Authorization: Bearer sk_live_..."

// SDK
const { sends, total } = await helmdesk.emails.list({
  to: 'dana@',
  status: ['bounced', 'blocked'],
})

Response

{
  "sends": [
    {
      "id": "d41f9a6b-88c5-4e2a-b7d0-1e5c3f9a2b64",
      "projectId": "11111111-1111-4111-8111-111111111111",
      "projectSlug": "ledgerly",
      "kind": "template",
      "status": "blocked",
      "toEmail": "dana@acme.io",
      "toName": "Dana Okafor",
      "subject": "Welcome to Ledgerly",
      "templateKey": "welcome",
      "environment": "live",
      "sendingMode": "live",
      "scheduledAt": null,
      "blocked": true,
      "blockReason": "Recipient is suppressed",
      "providerMessageId": null,
      "createdAt": "2026-09-09T10:15:00.000Z"
    }
  ],
  "total": 1,
  "page": 1,
  "limit": 25
}
GET/api/v1/emails/:sendIdemails:read

One send with its event timeline (queued → sent → delivered / bounced …) and the variables it was rendered with. Add ?includeHtml=true for the stored rendering — it is the whole email document, so it is off by default. 404 outside the project and environment.

Request

curl "https://helmdesk.dev/api/v1/emails/d41f9a6b-…?includeHtml=true" \
  -H "Authorization: Bearer sk_live_..."

// SDK
const send = await helmdesk.emails.get('d41f9a6b-…')
send.events.map((e) => e.event) // ['queued', 'sent', 'delivered']

Response

{
  "id": "d41f9a6b-88c5-4e2a-b7d0-1e5c3f9a2b64",
  "projectId": "11111111-1111-4111-8111-111111111111",
  "projectSlug": "ledgerly",
  "kind": "template",
  "status": "delivered",
  "toEmail": "dana@acme.io",
  "subject": "Welcome to Ledgerly",
  "templateKey": "welcome",
  "providerMessageId": "<20260909101502.1.abc@brevo>",
  "variables": { "name": "Dana" },
  "events": [
    { "event": "queued", "data": null, "occurredAt": "2026-09-09T10:15:00.000Z" },
    { "event": "sent", "data": { "messageId": "<2026…@brevo>" }, "occurredAt": "2026-09-09T10:15:02.000Z" },
    { "event": "delivered", "data": { "messageId": "<2026…@brevo>" }, "occurredAt": "2026-09-09T10:15:09.000Z" }
  ]
}

Resend and cancel

Both act on a send by id and need the emails:send scope. A resend is a new send through the normal gateway, so quota, suppression, sending mode and sandbox capture apply exactly as for the original; a cancel only works while the send is still scheduled.

POST/api/v1/emails/:sendId/resendemails:send

Send an existing email again to its original recipient. A template send re-renders with the template's current version; a custom send replays its stored subject and body. Returns the new send with originalId. 409 when the original cannot be replayed (template deleted, or a custom body that was never stored).

Request

curl -X POST https://helmdesk.dev/api/v1/emails/d41f9a6b-…/resend \
  -H "Authorization: Bearer sk_live_..."

// SDK
const again = await helmdesk.emails.resend('d41f9a6b-…')
// again.originalId === 'd41f9a6b-…', again.status === 'queued'

Response

{
  "id": "e52a0b7c-99d6-4f3b-c8e1-2f6d4a0b3c75",
  "originalId": "d41f9a6b-88c5-4e2a-b7d0-1e5c3f9a2b64",
  "projectId": "11111111-1111-4111-8111-111111111111",
  "projectSlug": "ledgerly",
  "kind": "template",
  "status": "queued",
  "to": "dana@acme.io",
  "subject": "Welcome to Ledgerly",
  "deduplicated": false,
  "blocked": false,
  "blockReason": null,
  "scheduledAt": null,
  "createdAt": "2026-09-09T11:02:00.000Z"
}
POST/api/v1/emails/:sendId/cancelemails:send

Cancel a send that is still waiting for its sendAt. The row stays in the log as canceled. 409 when it is no longer scheduled — it fired already, or never was.

Request

curl -X POST https://helmdesk.dev/api/v1/emails/d41f9a6b-…/cancel \
  -H "Authorization: Bearer sk_live_..."

// SDK
await helmdesk.emails.cancel('d41f9a6b-…')

Response

{
  "id": "d41f9a6b-88c5-4e2a-b7d0-1e5c3f9a2b64",
  "projectId": "11111111-1111-4111-8111-111111111111",
  "projectSlug": "ledgerly",
  "status": "canceled",
  "to": "dana@acme.io",
  "subject": "Your trial ends in 3 days",
  "scheduledAt": "2026-09-12T09:00:00.000Z",
  "blockReason": "Canceled before scheduled send time",
  "createdAt": "2026-09-09T10:15:00.000Z"
}

Suppressions

The suppression list is the set of addresses the project will not email: hard bounces and complaints recorded from the provider, unsubscribes, and manual blocks. A send to a suppressed address is recorded with status blocked and blockReason: "Recipient is suppressed" instead of being delivered — so when a customer is not getting your emails, look here first. Listing needs emails:read; adding and removing need emails:manage. The list is per environment.

GET/api/v1/suppressionsemails:read

The project's suppressed addresses, newest first. Filters: email (substring), reason (bounce|complaint|unsubscribe|manual), page, limit.

Request

curl "https://helmdesk.dev/api/v1/suppressions?email=dana" \
  -H "Authorization: Bearer sk_live_..."

// SDK
const { suppressions } = await helmdesk.emails.suppressions.list({ email: 'dana' })

Response

{
  "suppressions": [
    {
      "id": "b7d2c4e6-3f19-4a8b-9c0d-5e6f7a8b9c0d",
      "projectId": "11111111-1111-4111-8111-111111111111",
      "projectSlug": "ledgerly",
      "email": "dana@acme.io",
      "reason": "bounce",
      "environment": "live",
      "createdAt": "2026-09-02T08:11:44.000Z"
    }
  ],
  "total": 1,
  "page": 1,
  "limit": 25
}
POST/api/v1/suppressionsemails:manage

Block an address. The email is lowercased; reason defaults to manual. Idempotent — an address already on the list comes back with created: false and its original reason, never a conflict.

Request

curl -X POST https://helmdesk.dev/api/v1/suppressions \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "email": "dana@acme.io", "reason": "unsubscribe" }'

// SDK
const s = await helmdesk.emails.suppressions.add({ email: 'dana@acme.io', reason: 'unsubscribe' })
// s.created === true

Response

{
  "id": "b7d2c4e6-3f19-4a8b-9c0d-5e6f7a8b9c0d",
  "projectId": "11111111-1111-4111-8111-111111111111",
  "projectSlug": "ledgerly",
  "email": "dana@acme.io",
  "reason": "unsubscribe",
  "environment": "live",
  "createdAt": "2026-09-09T11:20:00.000Z",
  "created": true
}
DELETE/api/v1/suppressions/:suppressionIdemails:manage

Let mail flow to the address again. 404 when the id is not in this project and environment. A provider hard bounce will suppress the address again if delivery keeps failing.

Request

curl -X DELETE https://helmdesk.dev/api/v1/suppressions/b7d2c4e6-… \
  -H "Authorization: Bearer sk_live_..."

// SDK
await helmdesk.emails.suppressions.remove('b7d2c4e6-…')

Response

{ "deleted": true, "id": "b7d2c4e6-3f19-4a8b-9c0d-5e6f7a8b9c0d", "email": "dana@acme.io" }

Plain-text previews

Both preview shapes (templateKey or subject + body) accept format: "text". Instead of html the response carries text: a readable plain-text conversion of the rendering — tags stripped, entities decoded, one line per block, list items bulleted, links as text (url). It is the compact way to check what an email says (and where its links point) without reading a full HTML document, which is what an AI agent wants from the preview_email tool.

const { subject, text } = await helmdesk.emails.preview({
  templateKey: 'welcome',
  variables: { name: 'Dana' },
  format: 'text',
})
// text:
// Welcome, Dana
//
// Your account is ready.
//
// Activate account (https://app.acme.io/activate?token=abc)

Delivery webhooks

The status on an email-log row moves from sent to delivered or bouncedbecause the provider told Helmdesk so. Brevo, Resend and SendGrid do that through a webhook you point at Helmdesk once, per set of credentials; a bounce or complaint also adds the recipient to the project's suppression list and fires your own email.bounced webhook. SMTP has no delivery events, and delivery tracking for Amazon SES (which reports through SNS) is not available yet — SES and SMTP sends stay at sent, and their bounces do not reach the suppression list on their own.

The URL to paste is shown next to the credentials it belongs to: under a shared provider in Admin Settings → Email providers, or in the Delivery webhook section of a project's Email config when the project holds its own keys. It names the credential row (?source=prv_… or cfg_…) so Helmdesk knows which secret to verify against before it reads a byte of the payload.

  • Resend signs every delivery with Svix. Create a webhook endpoint in Resend for the URL, select email.delivered, email.bounced and email.complained, then paste the endpoint's signing secret (whsec_…) into the provider form in Helmdesk. Helmdesk checks the svix-signature header against the raw body and rejects timestamps more than five minutes old.
  • SendGrid signs its Event Webhook with an ECDSA key pair. Under Settings → Mail Settings → Event Webhook, set the URL as the Post URL, select Delivered, Bounced, Spam Reports and Unsubscribe, and turn on Signed Event Webhook; then paste the verification key it shows (a base64 public key) into the provider form in Helmdesk. Helmdesk verifies the X-Twilio-Email-Event-Webhook-Signature header over the timestamp and raw body, rejects timestamps more than five minutes old, and applies every event in the batch. Only the public key is stored, so it cannot be used to forge an event.
  • Brevo does not sign its webhooks, so the URL Helmdesk shows carries a token (&token=whk_…). Add it under Transactional → Settings → Webhooks for delivered, hard bounce, soft bounce, complaint and unsubscribe. Treat the URL as a secret; Regenerate token in Helmdesk invalidates the old one immediately.

What a delivery webhook can and cannot do

A request that fails verification, names a credential row that does not exist, or arrives before a secret has been set gets a 401and changes nothing. A verified event is applied only to a send made with those credentials, matched by the provider's message id, and the address it suppresses is always the recipient of that send — never an address named in the payload. Events for a message id Helmdesk does not know are acknowledged and ignored.

Creating templates, and wrapping them in a layout

Templates are created by importing a .hbs file, not by a JSON POST. The import call is also where you attach a layout — see Email Templates for the full flow, including why a template can come back with layoutLinked: false.

Batch sending

POST /api/v1/emails/batch accepts an emails array (min 1, max 100 items). Each item has the same shape as a single send — templateKey and to.email are required; to.name, variables, and environment are optional — plus an optional per-item idempotencyKey (batch idempotency is a field on each item, not the Idempotency-Key header used by single send).

The response returns summary counts (total, sent, blocked, failed) plus a results array. Each item is delivered independently — a failed item does not fail the batch. On success a slot looks like { index, id, status, to, subject, deduplicated, blocked, blockReason, createdAt }; on failure it is { index, to, error }. Always inspect each result's error and blocked fields. Batch sending requires the emails:send scope and the Starter and up (the Free plan returns 403). One batch counts as a single request against the 100 requests/minute rate limit.

Via MCP, use the send_email_batch tool with an emails array where each item is { templateKey, to_email, to_name?, variables?, idempotencyKey? }.

One-off custom emails

Sometimes there is no template because there will never be a second send — a personal follow-up, a one-time notice, the answer to a question. Call the same endpoint with a subject and a Markdown body instead of a templateKey. The body is rendered to email-safe HTML and wrapped in a layout — pass layout to name a project or @account/layout key, or omit it for the built-in Helm Basic shell with your project's branding. Nothing you write goes through Handlebars: {{variables}}are sent literally, so write the real details. Delivery follows the project's normal rules (quota, suppression, sending mode, sandbox capture, scheduling), and the send appears in Email Logs marked Custom email. Batch sends stay template-only: a hundred custom emails is a campaign, which is what templates are for.

const note = await helmdesk.emails.sendCustom({
  to: { email: 'dana@example.com', name: 'Dana' },
  subject: 'About the export you reported',
  body: 'Hi Dana,\n\nThe CSV export is fixed and live. Thanks for the clear report!\n\n— Alex',
})
// note.kind === 'custom'

// Check the rendering first — same layout, same branding:
const { subject, html } = await helmdesk.emails.previewCustom({
  subject: 'About the export you reported',
  body: 'Hi Dana, …',
})

Via MCP, use send_custom_email and preview_custom_email ({ to_email, to_name?, subject, body, layout?, sendAt? }). The subject is capped at 200 characters and the body at 50,000.

Scheduled sends

Pass sendAt (ISO timestamp or Date, up to 30 days ahead) on a single or batch send. The email renders immediately and waits in status scheduled; suppression, pause, and allowlist rules are re-checked at fire time. A scheduled send can be canceled from Email Logs any time before it fires. A past sendAt sends immediately.

Environments & sending modes

Which data plane a send lands in is decided by your API key — sk_sandbox_ keys capture emails without delivering them (see Sandbox). For live sending, modes include live, test recipient (redirects to a single address), allowlist (only certain domains), and paused (mail is held until you resume).

See Email Templates for the template import API and shared template system.