Sending Email and SMS

The Short Version

import { RelayClient } from '@insureco/relay'

const relay = RelayClient.fromEnv()  // reads RELAY_URL + BIO_CLIENT_ID + BIO_CLIENT_SECRET

// Fire-and-forget — NEVER await in request handlers
relay.sendEmail({
  template: 'welcome',
  to: { email: user.email, name: user.name },
  data: { firstName: user.firstName, orgName: org.name },
}).catch((err) => logger.warn({ err }, 'Welcome email failed'))

Setup

Declare relay under dependencies in catalog-info.yaml. The scoped dependency provisions both things the SDK needs: RELAY_URL (the Janus proxy URL) and the BIO_CLIENT_ID/BIO_CLIENT_SECRET client-credentials it authenticates with.

spec:
  dependencies:
    - service: relay
      scopes: [relay:send]
      transport: janus      # gas-metered, JWT-verified via Janus /i/relay
      charge: service       # or callerOrg — who pays gas per send

RelayClient.fromEnv() reads RELAY_URL and BIO_CLIENT_ID/BIO_CLIENT_SECRET — all injected by the builder from the declaration above. Same-org grants auto-approve on deploy; a cross-org consumer's first deploy opens a scope-grant request that must be approved in Console → Permissions → API Access.

Do not declare relay under internalDependencies — that injects RELAY_URL but not the Bio-ID credentials, so RelayClient.fromEnv() throws No auth configured at runtime.

For local dev, add to .env.local:

RELAY_DIRECT_URL=http://localhost:4001

Fire-and-Forget (REQUIRED Pattern)

Email and SMS are never on the critical path. A relay outage must not break your API.

// ✅ CORRECT: fire-and-forget
relay.sendEmail({ ... }).catch((err) => logger.warn({ err }, 'Email failed'))

// ❌ WRONG: awaiting blocks your response
await relay.sendEmail({ ... })  // your API times out if relay is slow

Always include .catch(). Silent failures mean lost audit events.

Sending Email

With a built-in template

await relay.sendEmail({
  template: 'welcome',           // see Built-In Templates below
  to: { email: '[email protected]', name: 'Jane Doe' },
  data: { firstName: 'Jane', orgName: 'Acme Corp' },
})

With custom HTML

relay.sendEmail({
  content: {
    subject: 'Your quote is ready',
    html: '<h1>Hello {{name}},</h1><p>Your quote is attached.</p>',
    text: 'Hello {{name}}, your quote is ready.',  // plain text fallback
  },
  to: { email: '[email protected]', name: 'Jane' },
  data: { name: 'Jane' },
  options: {
    fromName: 'MyProgram',       // display name only, NOT the from address
    cc: ['[email protected]'],
    bcc: '[email protected]',
  },
}).catch((err) => logger.warn({ err }, 'Quote email failed'))

Sending SMS

relay.sendSMS({
  content: { text: 'Your code is {{code}}. Expires in 10 minutes.' },
  to: { phone: '+15551234567' },  // E.164 format required: +{country}{number}
  data: { code: '123456' },
}).catch((err) => logger.warn({ err }, 'SMS failed'))

Built-In Templates

NameRequired Data
welcomefirstName, orgName
invitationinviterName, orgName, inviteUrl
password-resetfirstName, resetUrl
magic-linkfirstName, magicUrl

CC and BCC

relay.sendEmail({
  content: { subject: 'Policy bound', html: '...' },
  to: { email: '[email protected]' },
  options: {
    cc: ['[email protected]'],         // visible to all recipients
    bcc: '[email protected]',     // hidden, max 50 addresses each
  },
})

With Metadata (for tracing)

relay.sendEmail({
  template: 'invitation',
  to: { email: invitee.email },
  data: { inviterName: user.name, orgName: org.name, inviteUrl },
  metadata: {
    sourceService: 'my-api',
    sourceAction: 'user_invited',
    correlationId: req.id,
  },
}).catch((err) => logger.warn({ err }, 'Invite email failed'))

Delivery Status & Webhooks

sendEmail() resolving only means relay accepted the message. Final delivery (the provider confirming the recipient's mail server accepted it, or a bounce) arrives later. There are two ways to learn it.

Pull — status lookup (always available)

const status = await relay.getStatus(messageId)
// status.status: 'sent' | 'delivered' | 'bounced' | 'failed'
// status.deliveredAt, status.failureReason

Or list/aggregate via relay.listMessages({ status: 'bounced', sourceService: 'my-api' }).

Push — delivery webhooks (real-time)

Register a URL once and relay POSTs a signed event whenever one of your messages is delivered/bounced/failed (relay learns this from the provider's webhook). Routing is by verified caller identity — you only receive events for messages your service actually sent.

1. Register (one-time; use your service token — the same BIO_CLIENT_ID/SECRET the SDK uses):

POST {RELAY_URL}/webhook-subscriptions
Authorization: Bearer {service-token}
{ "url": "https://my-api.tawa.pro/api/relay/delivery-webhook",
  "events": ["delivered", "bounced", "failed"] }
# → { "data": { "id", "url", "events", "secret": "whsec_..." } }   ← secret returned ONCE

Store the returned secret as a config secret (tawa config set RELAY_WEBHOOK_SECRET=whsec_... --secret). GET/DELETE {RELAY_URL}/webhook-subscriptions[/:id] manage existing subscriptions.

2. Receive & verify — relay POSTs JSON signed with HMAC-SHA256 over the raw body:

X-Relay-Event: delivered
X-Relay-Signature: sha256=<hmac>
{ "event": "delivered", "messageId", "providerMessageId", "channel",
  "recipient": { "email" }, "sourceService", "occurredAt", "reason"? }
import { createHmac, timingSafeEqual } from 'node:crypto'
// Verify over the RAW request body BEFORE JSON-parsing:
const expected = 'sha256=' + createHmac('sha256', process.env.RELAY_WEBHOOK_SECRET!).update(rawBody).digest('hex')
const ok = a.length === b.length && timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))

The webhook receiver is a public endpoint — the HMAC signature is its authentication; do not also gate it behind user auth.

  • Delivery POSTs are fire-and-forget with retries; a subscription that fails repeatedly is auto-disabled.
  • Keep a fallback: webhooks can be missed, so periodically reconcile pending rows with getStatus() (e.g. a cron) — push is primary, poll is the safety net.
  • SDK helper methods for subscription management are not yet exposed — register via the HTTP endpoint above.

Custom Sending Domains

By default every send goes out as [email protected]. To send as your own address (for example [email protected]), the domain must be authenticated on the platform SendGrid account and your service must be granted that sender. Both are platform-admin operations on relay (relay:admin); you then pass options.fromEmail as usual.

relay.sendEmail({
  content: { subject, html, text },
  to: { email },
  options: { fromName: 'PolicyBlockchain Community', fromEmail: '[email protected]' },
}).catch((err) => logger.warn({ err }, 'Email failed'))

How a domain is onboarded (platform admin)

  1. POST /sending-domains { domain }: relay calls SendGrid domain authentication (automatic_security, using relay's own key; an existing SendGrid entry is reused) and returns the three CNAMEs to publish (mail_cname, dkim1, dkim2).
  2. Publish them at the DNS provider, DNS only (no Cloudflare proxy), and never overwrite existing records.
  3. POST /sending-domains/:domain/validate until status is verified (minutes).
  4. POST /sending-domains/:domain/senders { serviceId, localParts: ["community"] } for each caller. serviceId is the verified caller identity: the OAuth client id (client_... or {service}-prod, as shown in messagelogs.metadata.callerClientId). Use ["*"] for any local-part.

What relay enforces

SituationResult
From on the default domainunchanged
From on a registered domain, verified, caller allow-listed for that local-partsent as requested
From on a registered domain, but unverified or caller not allowedfalls back to [email protected], logs a warning, emits relay.from.rejected. The send never fails
From on an unregistered domainlegacy behaviour (BYOP accounts honour it; the platform provider ignores it)

The caller identity is the verified client id or Janus service id, never metadata.sourceService, which a caller can set to anything. Creating or patching a platform ServiceProvider whose From domain is unverified returns 409 DOMAIN_NOT_VERIFIED.

If a custom From is not arriving, check relay.from.rejected in Septor, then the sender's messagelogs.sender and metadata.callerClientId. A fallback is deliberate, not an outage.

Septor events: sending_domain.created|verified|failed|sender_allowed|sender_removed. failed means a previously verified domain stopped validating (a DNS record was removed).

Sandbox Mode

In sandbox and local dev, RELAY_MODE=sandbox is set automatically. Relay logs messages instead of sending them — no real emails or SMS are delivered. You never set this manually.

What NOT to Do

// ❌ WRONG: deprecated 'from' field
options: { from: 'My Service' }

// ✅ CORRECT
options: { fromName: 'My Service' }

// ❌ WRONG: legacy JWT auth
new RelayClient({ jwtSecret: process.env.JWT_SECRET, programId: '...' })

// ✅ CORRECT
RelayClient.fromEnv()

// ❌ WRONG: await in request handler
app.post('/api/send', async (req, res) => {
  await relay.sendEmail({ ... })  // blocks until delivery
  res.json({ ok: true })
})

// ✅ CORRECT
app.post('/api/send', async (req, res) => {
  relay.sendEmail({ ... }).catch((err) => logger.warn({ err }, 'Failed'))
  res.json({ ok: true })  // responds immediately
})

Key Facts

  • fromName sets the display name only — the actual sender is always your org's domain or [email protected]
  • Retries default to 2 — set retries: 0 if duplicates are unacceptable (e.g. payment receipts)
  • Templates use {{variable}} Handlebars-style interpolation
  • SMS requires E.164 format: +15551234567 (country code required, no spaces or dashes)
  • Max 50 recipients per CC or BCC field

Last updated: October 6, 2026