Skip to content

Messaging

Messaging lets your App send messages to your Visitors — transactional and broadcast email, SMS, and push notifications — from your handler code.

Your App always calls sdk.messaging.*. The platform selects the provider, injects credentials, validates input, enforces the suppression list, applies per-App rate limits, and records a delivery ledger. Your handler never talks to Resend, Twilio, or FCM directly, and the provider keys never appear in your source or logs.

Email runs on Resend by default (Amazon SES and SendGrid are selectable at the platform level). SMS runs on Twilio, and push runs on Firebase Cloud Messaging (FCM). Switching the email provider changes nothing in your code.

ChannelStatusNotes
EmailGAResend / SES / SendGrid — live-verified end to end, including delivery-webhook ingestion.
SMSPreviewTwilio API contract exists (sendSms, suppression, ledger); live provider delivery is not yet end-to-end verified.
PushPreviewFCM API contract exists (sendPush, suppression, ledger); live provider delivery is not yet end-to-end verified.

Messaging is available as soon as one provider key is set. Use set_secret to store the key your provider needs — you never paste it into code; the SDK reads it through the platform on each send.

ChannelSecret name(s)Provider
EmailRESEND_API_KEYResend (default)
EmailAWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEYAmazon SES
EmailSENDGRID_API_KEYSendGrid
SMSTWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKENTwilio
PushFCM_SERVICE_ACCOUNT_JSONFCM

FCM_SERVICE_ACCOUNT_JSON must be the full Google service-account JSON string (including client_email, private_key, and project_id). SES needs both AWS keys and signs with the us-east-1 region by default — set the AWS_REGION env binding to override.

If a send needs a key that is not set, the SDK throws MessagingError with kind: "secret-missing" (status 412).

sendEmail sends one transactional email and resolves with the provider’s message id. It is fail-fast: one send, one throw on error.

sendEmail(input: SendEmailRequest): Promise<{ providerMessageId: string }>
type SendEmailRequest = {
to: string // a valid email address
from: string // a domain you have verified with the provider
subject?: string // required with inline html; omit when using a template
html?: string // inline HTML — mutually exclusive with `template`
text?: string
idempotencyKey?: string // a UUID
template?: TemplateRef // mutually exclusive with `html`
locale?: string
}

Inside a handler:

const sdk = c.get("sdk")
const { providerMessageId } = await sdk.messaging.sendEmail({
to: visitorEmail,
from: "My App <[email protected]>",
subject: "Welcome aboard",
html: "<p>Thanks for signing up.</p>",
})

The from address must use a verified domain

Section titled “The from address must use a verified domain”

This is the most common send failure. The from address must use a domain you have verified with your email provider. Until you verify your own sending domain, use the platform-verified sender exactly: [email protected] (a display-name wrapper is fine — "My App <[email protected]>").

Never invent a domain. The provider rejects an unverified sender at send time and the SDK throws MessagingError with kind: "validation". A validation error that mentions the domain or the from address means your own sending domain needs its DKIM, SPF, and DMARC DNS records — follow the domain-verification runbook to set those up before sending from your domain.

Inline html and template are mutually exclusive

Section titled “Inline html and template are mutually exclusive”

Pass exactly one of html or template. Passing both, or neither, throws a validation error before any network call.

Sending to many Visitors — use enqueueEmail

Section titled “Sending to many Visitors — use enqueueEmail”

sendEmail sends a single email synchronously. For a broadcast to a list, do not loop sendEmail over the recipients. Call enqueueEmail once per recipient instead: it takes the same input, checks the suppression list immediately, then hands the send to the broadcast queue and returns a jobId.

enqueueEmail(input: SendEmailRequest): Promise<{ jobId: string }>
const sdk = c.get("sdk")
for (const recipient of recipients) {
await sdk.messaging.enqueueEmail({
to: recipient,
from: "My App <[email protected]>",
subject: "Product update",
html: body,
})
}

The broadcast path is retry-persistent and re-checks suppression when each message drains, so it is safe for large lists. Because the actual send happens later, enqueueEmail does not throw if the downstream send fails — it returns the jobId and the delivery outcome is recorded asynchronously.

Broadcast email rides on the Scheduling Capability. If Scheduling is not configured for your App, enqueueEmail throws kind: "not-available" (status 412); sendEmail is unaffected.

Preview. The sendSms API contract below exists and is implemented — suppression, rate-limiting, and the delivery ledger apply the same as email — but live delivery through Twilio has not yet been end-to-end verified. Treat this channel as Preview until that verification lands.

sendSms sends a text message through Twilio.

sendSms(input: SendSmsRequest): Promise<{ providerMessageId: string }>
type SendSmsRequest = {
to: string // E.164 / normalized phone, 1–64 chars
body?: string // inline body — mutually exclusive with `template`; 1–1600 chars
from?: string // optional sender id, 1–64 chars
idempotencyKey?: string
template?: TemplateRef
locale?: string
}
const sdk = c.get("sdk")
await sdk.messaging.sendSms({
to: "+15551234567",
body: "Your verification code is 481920.",
})

Preview. The sendPush API contract below exists and is implemented — suppression, rate-limiting, and the delivery ledger apply the same as email — but live delivery through FCM has not yet been end-to-end verified. Treat this channel as Preview until that verification lands.

sendPush delivers a notification to one FCM device token.

sendPush(input: SendPushInput): Promise<{ providerMessageId: string }>
type SendPushInput = {
token: string // FCM device registration token
title: string // 1–256 chars
body: string // 1–4096 chars
data?: Record<string, string> // optional key/value payload
idempotencyKey?: string
}
const sdk = c.get("sdk")
await sdk.messaging.sendPush({
token: deviceToken,
title: "Order shipped",
body: "Your order is on its way.",
data: { orderId: "1042" },
})

Email, SMS, and push are three distinct methods. Each takes its own input shape and requires its own provider key — they are never interchangeable.

Register a reusable template once with registerTemplate, then reference it from sendEmail, enqueueEmail, or sendSms by id and fill its variables per send. A template can carry email and/or SMS channels, plus per-locale overrides.

registerTemplate(input: RegisterTemplateInput): void
type RegisterTemplateInput = {
id: string
channels: {
email?: { subject: string; html: string; text?: string }
sms?: { body: string }
}
variables: string[]
locales?: Record<string, Partial<RegisterTemplateInput["channels"]>>
}

A send references a template through TemplateRef:

type TemplateRef = {
id: string
locale?: string
variables: Record<string, string>
}
const sdk = c.get("sdk")
sdk.messaging.registerTemplate({
id: "welcome",
channels: {
email: { subject: "Welcome, {{name}}", html: "<p>Hi {{name}}, welcome!</p>" },
},
variables: ["name"],
})
await sdk.messaging.sendEmail({
to: visitorEmail,
from: "My App <[email protected]>",
template: { id: "welcome", variables: { name: "Sam" } },
})

When you send with a template, omit subject and html — the template supplies them.

Suppression — bounces, complaints, and unsubscribes

Section titled “Suppression — bounces, complaints, and unsubscribes”

Every send runs through the suppression list first. If a destination is suppressed, the send throws MessagingError with kind: "suppressed" (status 403) before any network call or credential access. Do not retry a suppressed send — the platform maintains the list. Manage it directly when you need to:

type MessageChannel = "email" | "sms" | "push"
type SuppressionReason =
| "hard_bounce" | "soft_bounce" | "complaint" | "manual_unsubscribe" | "admin"
suppress(input: { channel: MessageChannel; destination: string; reason: SuppressionReason }): Promise<void>
unsuppress(input: { channel: MessageChannel; destination: string }): Promise<void>
isSuppressed(input: { channel: MessageChannel; destination: string }): Promise<boolean>
const sdk = c.get("sdk")
await sdk.messaging.suppress({
channel: "email",
destination: visitorEmail,
reason: "manual_unsubscribe",
})

Your email provider reports bounces, complaints, and deliveries through webhooks. Register a receiver with set_webhook (the signing secret is named <PROVIDER>_WEBHOOK_SECRET_<ID>), then parse incoming events with parseDeliveryEvent, a pure helper exported from @anyknown/app-server. It returns null for events it does not recognize and never throws.

import { parseDeliveryEvent } from "@anyknown/app-server"
type DeliveryEventProvider = "resend" | "ses" | "sendgrid"
type DeliveryEvent = {
type: "bounce" | "complaint" | "delivered" | "deferred"
destination: string
providerMessageId?: string
hardBounce?: boolean
}
parseDeliveryEvent(provider: DeliveryEventProvider, event: WebhookEvent): DeliveryEvent | null

In your webhook handler, suppress on hard bounces and complaints, and record delivery outcomes so your ledger stays accurate:

const sdk = c.get("sdk")
const delivery = parseDeliveryEvent("resend", event)
if (delivery === null) return c.json({ ok: true }) // unrecognized — ignore
if (delivery.hardBounce || delivery.type === "complaint") {
await sdk.messaging.suppress({
channel: "email",
destination: delivery.destination,
reason: delivery.hardBounce ? "hard_bounce" : "complaint",
})
}
if (delivery.providerMessageId) {
await sdk.messaging.recordDelivery({
providerMessageId: delivery.providerMessageId,
status: delivery.type === "delivered" ? "delivered" : "bounced",
})
}

recordDelivery updates the delivery ledger entry for a message:

type MessageDeliveryStatus =
| "pending" | "delivered" | "bounced" | "complained" | "opened" | "clicked"
recordDelivery(input: { providerMessageId: string; status: MessageDeliveryStatus }): Promise<void>

Every send can throw MessagingError. It carries a structured kind and an HTTP status, so catch it explicitly and surface the status to the Visitor.

type MessagingErrorKind =
| "secret-missing" // 412 — provider key not set
| "validation" // 400 — malformed input or unverified sender domain
| "rate-limited" // 429 — throttled; see err.retryAfterSec
| "auth" // 502 — the provider rejected the key
| "provider-error" // 502 — provider upstream failure
| "suppressed" // 403 — destination is suppressed; do NOT retry
| "not-available" // 412 — broadcast queue unavailable (Scheduling not configured)
import { MessagingError } from "@anyknown/app-server"
const sdk = c.get("sdk")
try {
await sdk.messaging.sendEmail({
to: visitorEmail,
from: "My App <[email protected]>",
subject: "Receipt",
html: receiptHtml,
})
return c.json({ ok: true })
} catch (err) {
if (err instanceof MessagingError) {
return new Response(err.message, { status: err.status })
}
throw err
}

MessagingError is a class — serialize it with new Response(err.message, { status: err.status }). Do not JSON.stringify it or return Response.json(err): that drops the prototype and the status mapping.

The error also exposes retryAfterSec (present when kind is "rate-limited") and resendErrorName, which carries the provider’s raw error code or name across every channel and provider. The field name is kept for compatibility; read it as “provider error code”, not anything Resend-specific.

  • The SDK is the only correct path. Calling a provider’s API directly with fetch bypasses suppression, rate-limiting, credential safety, and error normalization. Always go through sdk.messaging.*.

  • Email, SMS, and push are separate methods. sendEmail, sendSms, and sendPush are never overloads of one another. Each has its own input shape and its own required secrets.

  • Use enqueueEmail for bulk, not a sendEmail loop. The broadcast path retries failed sends and re-checks suppression at drain time; a sendEmail loop does neither.

  • Suppression runs before the network. A suppressed destination short-circuits with a 403 before any credential is touched. Never retry it — clear it with unsuppress only when you intend to send again.

  • Rate limits are an approximate ceiling. Per-App and per-recipient send limits use a coarse, fail-open counter (tune them with the MESSAGING_RATELIMIT_APP_PER_MIN and MESSAGING_RATELIMIT_DEST_PER_DAY env bindings). Under store pressure the limit is skipped rather than blocking your send, so treat it as a soft ceiling, not a hard guarantee.

  • Provider choice is invisible to your code. Moving email from Resend to SES or SendGrid changes only the platform configuration and your secret — your sdk.messaging.sendEmail calls stay identical, and the provider SDKs never enter your App’s bundle.