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.
Channel status
Section titled “Channel status”| Channel | Status | Notes |
|---|---|---|
| GA | Resend / SES / SendGrid — live-verified end to end, including delivery-webhook ingestion. | |
| SMS | Preview | Twilio API contract exists (sendSms, suppression, ledger); live provider delivery is not yet end-to-end verified. |
| Push | Preview | FCM API contract exists (sendPush, suppression, ledger); live provider delivery is not yet end-to-end verified. |
Set up your provider key
Section titled “Set up your provider key”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.
| Channel | Secret name(s) | Provider |
|---|---|---|
RESEND_API_KEY | Resend (default) | |
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY | Amazon SES | |
SENDGRID_API_KEY | SendGrid | |
| SMS | TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN | Twilio |
| Push | FCM_SERVICE_ACCOUNT_JSON | FCM |
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).
Sending email
Section titled “Sending email”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, 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, 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.
Sending SMS
Section titled “Sending SMS”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.",})Sending push notifications
Section titled “Sending push notifications”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.
Templates
Section titled “Templates”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, 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",})Handling delivery webhooks
Section titled “Handling delivery webhooks”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 | nullIn 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>Handling errors
Section titled “Handling errors”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, 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.
Things to know
Section titled “Things to know”-
The SDK is the only correct path. Calling a provider’s API directly with
fetchbypasses suppression, rate-limiting, credential safety, and error normalization. Always go throughsdk.messaging.*. -
Email, SMS, and push are separate methods.
sendEmail,sendSms, andsendPushare never overloads of one another. Each has its own input shape and its own required secrets. -
Use
enqueueEmailfor bulk, not asendEmailloop. The broadcast path retries failed sends and re-checks suppression at drain time; asendEmailloop does neither. -
Suppression runs before the network. A suppressed destination short-circuits with a
403before any credential is touched. Never retry it — clear it withunsuppressonly 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_MINandMESSAGING_RATELIMIT_DEST_PER_DAYenv 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.sendEmailcalls stay identical, and the provider SDKs never enter your App’s bundle.