Overview
Reaching humans — declare email, SMS, WhatsApp, and push templates, send through fx.send, track receipts and consent.
Channel is how your backend reaches a person — the order-confirmation email, the SMS
sign-in code, a WhatsApp notice, or a device push. You declare a template on a medium, fill a
{{field}} body catalog, and send from a Flow with fx.send.
For developers wiring Mailpit locally and Resend / Taqnyat / FCM in production — templates and
drivers first, never vendor SDKs inside do.
The one rule
Declare a template (channel.email(…).template(…)), then fx.send(template, { to, data }).
Bodies live in the catalog (subject / text / html), not on the declare call. Consent and
prior bounces suppress before any driver runs.
Around one fx.send
via: ["smtp", "resend"]clean send- 1Consent
not suppressed — provider may be contacted
- 2Locale
default:en → body en
- 3Drivers
smtp — single attempt
- →
Receipt — driver, ok/error, timestamp, message id
Smallest Example
Declare an email template
import { channel } from "okengine";
import { z } from "zod";
const mail = channel.email({ from: "Notes <notes@localhost>" });
export const noteCreatedMail = mail.template("note-created", {
locales: ["en"],
schema: z.object({
id: z.string(),
title: z.string(),
}),
});Import this module before oke() so auto-registry adopts the template (or pass it in
oke({ channel: { templates: […] } })).
Send from a Flow
import { on, flow } from "okengine";
import { noteCreatedMail } from "@/core/channel";
import { noteCreated } from "./signals";
export const onCreated = on(
noteCreated,
flow("notes.onCreated", {
do: async (payload, fx) => {
await fx.send(noteCreatedMail, {
to: "you@localhost",
data: { id: payload.id, title: payload.title },
});
},
}),
);The compiler stamps sends: ["note-created"] on the Flow’s effects (template name — not
email:note-created).
See it locally
With drivers.channel.email.dev: "smtp" and Mailpit pinned, open the Mailpit UI
(MAILPIT_UI_URL). Missing catalog bodies fall back to subject: note-created and
text: JSON.stringify(data).
Progressive Patterns
From a bare send to catalog bodies, locale, and same-medium failover:
Template handle + recipient — catalog optional for local smoke tests:
await fx.send(noteCreatedMail, {
to: "alice@example.com",
data: { id: "n1", title: "Hello" },
});Declaration Reference
| Declaration | Signature | Purpose |
|---|---|---|
channel.email | channel.email(options?) | Email medium binder |
channel.sms | channel.sms(options?) | SMS medium binder |
channel.whatsapp | channel.whatsapp(options?) | WhatsApp medium binder |
channel.push | channel.push(options?) | Push medium binder |
binder.template | binder.template(name, options?) | Auto-registered template |
channel.template | channel.template(name, options?) | Medium-agnostic — not auto-registered |
Medium options
| Option | Type | Default | Meaning |
|---|---|---|---|
from | string | — | Default sender (email From / SMS sender id) |
sender | string | — | Alias stored as from |
Template options
| Option | Type | Default | Meaning |
|---|---|---|---|
description | string | name | Console / docs label |
locales | string[] | — | Declared locale tags for the template |
schema | Schema | — | Payload shape (Zod / Standard Schema) |
from | string | binder’s | Override sender on agnostic channel.template only |
medium | medium | "email" | Only on channel.template() |
Empty name throws TypeError: channel.template: name is required.
There is no subject / body / html on declare — those belong in the catalog.
fx surface
| Method | Capability / sends | Meaning |
|---|---|---|
fx.send(template, opts?) | template name | Deliver through the medium’s driver chain |
fx.sendOtp(opts) | "sms-otp" | Provider-managed SMS OTP (Taqnyat Verify) |
fx.verifyOtp(opts) | "sms-otp" | Check a provider OTP code |
fx.deliverOtp(opts) | "auth-otp" | App-owned OTP across email / SMS / WhatsApp |
fx.send options
| Option | Type | Meaning |
|---|---|---|
to | string | Recipient (email / E.164 / FCM token / …) |
data | object | Interpolated into {{field}} catalog bodies |
via | refs | Same-medium driver order for failover |
locale | string | Explicit locale (wins) |
profileLocale | string | Profile locale step |
acceptLanguage | string | Raw Accept-Language header |
Dry-run records would have fired and never contacts a provider. Undeclared send → OKE1004
UNDECLARED_SEND.
Per-environment drivers
Email defaults from DRIVER_DEFAULTS.channel.email. SMS / WhatsApp / push are opt-in:
import { defineConfig } from "okengine/config";
export default defineConfig({
drivers: {
channel: {
email: { dev: "smtp", test: "console", prod: "smtp" },
// sms: { prod: "taqnyat" },
// whatsapp: { prod: "wa-cloud" },
},
},
images: {
channel: { email: "axllent/mailpit:v1.31.1" },
},
});| Key | Default (dev / test / prod) | Driver ids |
|---|---|---|
channel.email | smtp / console / smtp | console · smtp · resend · sndr · taqnyat-mail |
channel.sms | none | taqnyat · msegat · unifonic (console opens nothing) |
channel.whatsapp | none | wa-cloud · taqnyat-whatsapp |
channel.push | none — not auto-bound | Pass oke({ channel: { drivers: […] } }) with webpush / fcm |
Env knobs: Environment variables. Local SMTP catcher: Mailpit.
The Capabilities of Channel
SMTP / Mailpit, Resend, SNDR, Taqnyat Mail — templates and catalogs.
SMS
Transactional SMS and provider-managed OTP (Taqnyat Verify).
wa-cloud and Taqnyat WhatsApp medium binders.
Push
FCM device tokens and Web Push (VAPID) driver binding.
Receipts
In-memory ledger, outcomes taxonomy, consent and bounce suppression.
Troubleshooting
fx.send named a template that was never declared or not adopted at boot. Import the medium
binder module before oke(), or pass channel.templates explicitly.
Cause: Flow "{flow}" sends "{resource}" without declaring it. Touch the template handle
inside do (inference) or list effects: { sends: ["note-created"] }.
No email-capable driver is bound (or via filtered them all out). Check drivers.channel.email
and SMTP_URL / provider API keys for the active env.
Email is pinned to smtp but neither SMTP_URL nor OKE_CHANNEL_EMAIL_URL is set. For local
Docker, run the Mailpit stack so compose writes the URL.
Boot warns: Channel suppression/consent/receipts default to process-local memory… Opt-out on one
instance is invisible to others until you inject shared stores or run a single Channel consumer.
See Receipts.
Learn more
- Email — drivers, catalog, Mailpit
- SMS —
fx.sendOtp/fx.verifyOtp - WhatsApp —
channel.whatsapp+ boot drivers - Push — FCM / Web Push binding
- Receipts — ledger, outcomes, suppression
- OTP plugin —
/auth/otp/*over Channel - fx —
fx.sendoptions - i18n — Channel catalogs vs
fx.t