Skip to content

Recipe: Send Transactional Email

Send emails from Centrali functions using your preferred email provider (SendGrid, Resend, AWS SES, Postmark, etc.).


Problem

Your app needs to send transactional emails — welcome messages, password resets, order confirmations, notifications. You want to trigger these from Centrali when data changes or events occur.

Solution

Create a function that calls your email provider's API, then wire it to an event-driven trigger so emails send automatically when records are created or updated.


Example: SendGrid

Step 1: Create the function

async function run() {
  const apiKey = triggerParams.sendgridApiKey;
  const fromEmail = triggerParams.fromEmail || 'noreply@yourapp.com';

  const { to, subject, html, text } = executionParams.payload || {};

  if (!to || !subject) {
    return { success: false, error: 'Missing required fields: to, subject' };
  }

  try {
    const response = await api.httpPost(
      'https://api.sendgrid.com/v3/mail/send',
      {
        personalizations: [{ to: [{ email: to }] }],
        from: { email: fromEmail },
        subject,
        content: [
          { type: 'text/html', value: html || text || '' },
        ],
      },
      {
        headers: {
          'Authorization': `Bearer ${apiKey}`,
          'Content-Type': 'application/json',
        },
      }
    );

    api.log({ sent: true, to, subject });
    return { success: true, data: { sent: true } };
  } catch (error) {
    api.log({ error: error.message, to });
    return { success: false, error: error.message };
  }
}

Step 2: Add domain and configure trigger

  1. Add api.sendgrid.com to Logic → Domains
  2. Create an endpoint trigger with static params:
    {
      "sendgridApiKey": "SG.your_api_key_here",
      "fromEmail": "hello@yourapp.com"
    }
    

Step 3: Call from your app

await centrali.triggers.invokeEndpoint('send-email', {
  payload: {
    to: 'user@example.com',
    subject: 'Welcome to MyApp!',
    html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
  },
});

Example: Resend

async function run() {
  const apiKey = triggerParams.resendApiKey;
  const fromEmail = triggerParams.fromEmail || 'noreply@yourapp.com';
  const { to, subject, html } = executionParams.payload || {};

  const response = await api.httpPost(
    'https://api.resend.com/emails',
    { from: fromEmail, to, subject, html },
    { headers: { 'Authorization': `Bearer ${apiKey}` } }
  );

  return { success: true, data: { id: response.id } };
}

Add api.resend.com to allowed domains.


Example: AWS SES (Simple Email Service)

AWS SES uses the REST API with SigV4 signing, which is complex. The simplest approach is to use SES v2's simpler HTTP API with an IAM access key:

async function run() {
  const { accessKeyId, secretAccessKey, region } = triggerParams;
  const { to, subject, html } = executionParams.payload || {};
  const fromEmail = triggerParams.fromEmail;

  // SES v2 SendEmail via simple HTTPS endpoint
  // Note: For production, consider using an intermediary API gateway
  // or a service like Resend/SendGrid which wraps SES with a simpler API.
  const response = await api.httpPost(
    `https://email.${region}.amazonaws.com/v2/email/outbound-emails`,
    {
      FromEmailAddress: fromEmail,
      Destination: { ToAddresses: [to] },
      Content: {
        Simple: {
          Subject: { Data: subject },
          Body: { Html: { Data: html } },
        },
      },
    },
    {
      headers: {
        'Content-Type': 'application/json',
        'X-Amz-Access-Key': accessKeyId,
      },
    }
  );

  return { success: true, data: response };
}

Add email.{region}.amazonaws.com to allowed domains.

AWS SigV4 Signing

AWS APIs typically require SigV4 request signing, which is difficult to implement inside a compute function. For AWS SES, consider using a wrapper service like Resend (which uses SES under the hood) or placing an API Gateway in front of SES with IAM auth.


Event-driven: Send email on record creation

Wire the email function to fire automatically when a record is created:

  1. Create an event-driven trigger on the record.created event for your collection
  2. In the function, extract the record data from executionParams:
async function run() {
  const apiKey = triggerParams.sendgridApiKey;
  const fromEmail = triggerParams.fromEmail;

  // Event-driven triggers receive the record in executionParams
  const record = executionParams.payload;

  if (!record?.data?.email) {
    api.log({ skipped: true, reason: 'No email field on record' });
    return { success: true, data: { skipped: true } };
  }

  await api.httpPost(
    'https://api.sendgrid.com/v3/mail/send',
    {
      personalizations: [{ to: [{ email: record.data.email }] }],
      from: { email: fromEmail },
      subject: `Welcome, ${record.data.name || 'there'}!`,
      content: [{ type: 'text/html', value: '<h1>Welcome!</h1>' }],
    },
    { headers: { 'Authorization': `Bearer ${apiKey}` } }
  );

  return { success: true, data: { emailed: record.data.email } };
}

HTML Templates

The examples above hardcode the email HTML in the function code. That works for simple emails, but for richer templates you'll want to separate content from logic. There are three approaches — pick the one that fits your team.

Approach 1: Inline template helper

Build the HTML inside the function using a helper. Good for developer-managed templates where everything lives in code:

async function run() {
  const apiKey = triggerParams.resendApiKey;
  const fromEmail = triggerParams.fromEmail;
  const { to, name, orderNumber, items, total } = executionParams.payload || {};

  const html = buildOrderConfirmation({ name, orderNumber, items, total });

  await api.httpPost(
    'https://api.resend.com/emails',
    { from: fromEmail, to, subject: `Order #${orderNumber} confirmed`, html },
    { headers: { 'Authorization': `Bearer ${apiKey}` } }
  );

  return { success: true, data: { sent: true } };
}

function buildOrderConfirmation({ name, orderNumber, items, total }) {
  const itemRows = items
    .map(item => `
      <tr>
        <td style="padding: 8px; border-bottom: 1px solid #eee;">${item.name}</td>
        <td style="padding: 8px; border-bottom: 1px solid #eee; text-align: right;">$${item.price.toFixed(2)}</td>
      </tr>
    `)
    .join('');

  return `
    <!DOCTYPE html>
    <html>
    <body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
      <h1 style="color: #333;">Order Confirmed</h1>
      <p>Hi ${name || 'there'},</p>
      <p>Your order <strong>#${orderNumber}</strong> has been confirmed.</p>
      <table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
        <thead>
          <tr style="background: #f5f5f5;">
            <th style="padding: 8px; text-align: left;">Item</th>
            <th style="padding: 8px; text-align: right;">Price</th>
          </tr>
        </thead>
        <tbody>
          ${itemRows}
          <tr>
            <td style="padding: 8px; font-weight: bold;">Total</td>
            <td style="padding: 8px; text-align: right; font-weight: bold;">$${total.toFixed(2)}</td>
          </tr>
        </tbody>
      </table>
      <p style="color: #888; font-size: 12px;">Thank you for your purchase.</p>
    </body>
    </html>
  `;
}

Approach 2: api.renderTemplate() with Handlebars

The compute runtime includes a built-in api.renderTemplate() method powered by Handlebars. This supports nested variables ({{order.total}}), conditionals ({{#if}}...{{/if}}), and loops ({{#each items}}). See the Handlebars templating in compute functions blog post for a full walkthrough.

async function run() {
  const apiKey = triggerParams.resendApiKey;
  const fromEmail = triggerParams.fromEmail;
  const { to, name, orderNumber, items, total } = executionParams.payload || {};

  // Define the template with Handlebars syntax
  const subjectTemplate = 'Order #{{orderNumber}} Confirmed';
  const htmlTemplate = `
    <h1>Order Confirmed</h1>
    <p>Hi {{name}},</p>
    <p>Your order <strong>#{{orderNumber}}</strong> has been confirmed.</p>
    <table style="width: 100%; border-collapse: collapse;">
      {{#each items}}
      <tr>
        <td style="padding: 8px;">{{this.name}}</td>
        <td style="padding: 8px; text-align: right;">\${{this.price}}</td>
      </tr>
      {{/each}}
    </table>
    <p><strong>Total: \${{total}}</strong></p>
  `;

  // Build the context object
  const context = { name, orderNumber, items, total };

  // Render using the built-in template engine
  const subject = api.renderTemplate(subjectTemplate, context);
  const html = api.renderTemplate(htmlTemplate, context);

  await api.httpPost(
    'https://api.resend.com/emails',
    { from: fromEmail, to, subject, html },
    { headers: { 'Authorization': `Bearer ${apiKey}` } }
  );

  return { success: true, data: { sent: true } };
}

For teams where non-developers need to edit email content without touching code, store templates as records in a collection. The function fetches the template at runtime and renders it with api.renderTemplate().

Step 1: Create an email-templates collection with these fields:

Field Type Description
eventType text Event identifier (e.g., order_confirmation, welcome, password_reset)
name text Human-readable name for the Console UI
subject text Subject line with {{variable}} placeholders
html text Full HTML body with {{variable}} placeholders
fromName text Sender display name (e.g., MyApp)
fromEmail text Sender email (e.g., orders@myapp.com)
isActive boolean Toggle to enable/disable without deleting

Step 2: Create a template record in the Console:

{
  "eventType": "order_confirmation",
  "name": "Order Confirmation",
  "subject": "Order #{{order.orderNumber}} confirmed",
  "html": "<h1>Hi {{customer.name}},</h1><p>Your order <strong>#{{order.orderNumber}}</strong> for {{order.total}} has been confirmed.</p>{{#each items}}<p>{{this.name}} — {{this.price}}</p>{{/each}}<p>We'll notify you when it ships.</p>",
  "fromName": "MyApp",
  "fromEmail": "orders@myapp.com",
  "isActive": true
}

Step 3: Function that fetches and renders:

async function run() {
  const apiKey = triggerParams.resendApiKey;
  const { to, eventType, context } = executionParams.payload || {};

  if (!eventType) {
    return { success: false, error: 'eventType is required' };
  }

  // Fetch the active template for this event
  const templates = await api.queryRecords('email-templates', {
    filter: {
      'data.eventType': eventType,
      'data.isActive': true,
    },
    pageSize: 1,
  });

  const items = templates?.items || templates?.data || [];
  if (items.length === 0) {
    api.log({ skipped: true, reason: `No active template for ${eventType}` });
    return { success: true, data: { skipped: true, reason: `No active template for ${eventType}` } };
  }

  const template = items[0].data?.data || items[0].data;

  // Render subject and HTML with the built-in template engine
  const subject = api.renderTemplate(template.subject, context);
  const html = api.renderTemplate(template.html, context);

  // Send via email provider
  const response = await api.httpPost(
    'https://api.resend.com/emails',
    {
      from: `${template.fromName} <${template.fromEmail}>`,
      to,
      subject,
      html,
    },
    { headers: { 'Authorization': `Bearer ${apiKey}` } }
  );

  api.log({ sent: true, to, eventType, resendId: response?.id });
  return { success: true, data: { sent: true, eventType, resendId: response?.id } };
}

Step 4: Call from your app:

await centrali.triggers.invokeEndpoint('send-email', {
  payload: {
    to: 'customer@example.com',
    eventType: 'order_confirmation',
    context: {
      customer: { name: 'Sarah' },
      order: { orderNumber: '10042', total: '$89.99' },
      items: [
        { name: 'Widget Pro', price: '$59.99' },
        { name: 'Widget Case', price: '$30.00' },
      ],
    },
  },
});

This approach gives non-technical team members full control over email content from the Console — they can edit copy, adjust layouts, toggle templates on/off, and add new event types without deploying code. The function just fetches the latest active template and renders it with api.renderTemplate().


Common gotchas

  • Allowed domains: Add your email provider's API domain to the allowed list, or requests will be blocked.
  • From address verification: Most providers require you to verify your sender domain or email address before sending. Do this in the provider's dashboard, not in Centrali.
  • Rate limits: Email providers have sending limits. For bulk sends, use the provider's batch API or queue emails in a collection and process them with a scheduled trigger.
  • HTML escaping: If you build HTML from user input, sanitize it to avoid injection. Don't trust record.data values in raw HTML.