Notifications
The ambient Notifications service sends push notifications, transactional email, SMS, phone-verification codes and in-app inbox messages from your backend, and manages the message templates behind them. Sends run with the runtime's service-role credential, so a handler can notify any user. The first thing to know is the thing older documentation got backwards: a channel delivers nothing until a provider is configured for it. There is no managed sender on a new project — email, SMS and verification each need a provider on the Environment before a send can succeed.
Quick example
// modules/todos/todo-assign.service.ts
import { Injectable, Log, Notifications } from "@palbase/backend";
@Injectable()
export class TodoAssignService {
// `Notifications` is ambient — imported, not injected. Nothing goes in the
// constructor for it, and nothing lists it in a module.
async notifyAssignee(assigneeId: string, todoTitle: string): Promise<void> {
const { error } = await Notifications.push.send({
to: assigneeId,
title: "New todo assigned",
body: `You were assigned: ${todoTitle}`,
deep_link: "myapp://todos",
channels: ["push", "inbox"], // deliver as push AND drop a copy in the in-app inbox
});
if (error) {
// A notification failure should not fail the request — log and move on.
Log.warn("push failed", error.code, error.message);
}
}
}
Every channel needs a provider first
| Channel | What it needs |
|---|---|
| Push | APNs (apns) or FCM (fcm) — they authenticate as your app, so nothing else can send for you |
one of sendgrid, ses, smtp or acs | |
| SMS | twilio |
| Phone verification | twilio — the same provider as SMS |
| Inbox | nothing — an inbox message is a row on your own stack |
Configure one with palbase notifications add <provider> in a linked checkout. It writes the sender to the stack immediately and puts the credential in the Environment's vault; there is no file to commit and no deploy to wait for. See Configuring providers below and Stack Settings.
A send on a channel with no provider is refused at the request, never accepted and quietly dropped:
503 provider_not_configured
No email provider configured for this stack — run `palbase notifications add` to configure one
The stack also says so at boot, once per channel, so an unconfigured channel is visible before the first send rather than after it:
WARN notifications: NOTHING WILL BE DELIVERED on this channel channel=email because="no email provider is configured"
Warning: An operator running their own deployment can configure a shared sender for every stack on it with
PALNOTIFY_SHARED_PROVIDERS__*. That is an operator act, not a default, and the Palbase cloud does not hand projects a shared email or SMS account — a stack that has not been told how to send mail says so rather than inheriting somebody else's. Assume you need your own provider.
Result envelope — notifications never throw
Every method resolves { data, error, status? }:
interface PalbaseResult<T> {
data: T | null;
error: { message?: string; code?: string } | null;
status?: number;
}
Check error and decide whether the failure matters to your flow. A network failure is enveloped too, so a send can never take down the request that made it.
Push — Notifications.push.send(params)
| Param | Type | Description |
|---|---|---|
to | string | string[] | { topic: string } | One user id, several, or a topic. |
title / body | string | Record<string, string> | Plain text, or a locale map (below). |
variables | Record<string, string> | Template variables. |
default_locale | string | Fallback locale for a locale map. |
data | Record<string, string> | Custom key/value payload delivered to the app. |
| Param | Type | Description |
|---|---|---|
image | string | Image URL. |
badge | number | App icon badge count. |
sound | string | Notification sound. |
deep_link | string | Link opened when the notification is tapped. |
collapse_key | string | Replaces earlier undelivered notifications with the same key. |
priority | "high" | "normal" | Delivery priority. |
ttl | number | Time to live. |
silent / content_available | boolean | Background delivery with no alert; wake the app (iOS). |
category | string | Message category — also the key a user's preferences are matched on. |
metadata | unknown | Arbitrary metadata stored with the message. |
channels | Array<"push" | "inbox"> | Fan the message out to several channels. |
inbox_action_url | string | Action URL for the inbox copy when fanning out. |
On success data is { message_id?, message_ids?, recipients }, or a per-channel response when channels is set.
These field names are already the wire names — push params are forwarded verbatim, with none of the rewriting the email and SMS clients do.
Localised title and body
Pass a locale map instead of a string and each device receives its own language, falling back to default_locale:
import { Notifications } from "@palbase/backend";
await Notifications.push.send({
to: { topic: "release-notes" },
title: { en: "New features!", tr: "Yeni özellikler!", de: "Neue Funktionen!" },
body: { en: "Tap to see what's new.", tr: "Yenilikleri görmek için dokun.", de: "Tippe für Neuigkeiten." },
default_locale: "en",
});
Multi-channel fan-out
When you pass channels, the response reports each channel separately:
import { Notifications } from "@palbase/backend";
declare const userId: string;
const { data } = await Notifications.push.send({
to: userId,
title: "Weekly summary",
body: "You completed 12 todos this week.",
channels: ["push", "inbox"],
});
// data.channels = {
// push: { status: "sent", message_id: "...", recipients: 1 },
// inbox: { status: "queued", message_id: "..." },
// }
Each outcome is { status: "queued" | "sent" | "skipped" | "failed", message_id?, message_ids?, recipients?, error? }. A channel can be skipped — for example when the user opted out of that category on that channel.
Email — Notifications.email.send(params)
import { Notifications } from "@palbase/backend";
await Notifications.email.send({
to: "ada@example.com",
subject: "Your weekly digest",
html: "<h1>4 open todos</h1>",
});
For anything you send more than once, store a template on the stack and send by slug:
import { Notifications } from "@palbase/backend";
await Notifications.email.send({
to: "ada@example.com",
templateSlug: "todo-digest",
variables: { name: "Ada", openCount: 4 },
});
| Param | Type | Description |
|---|---|---|
to | string | string[] | Recipient address(es). |
subject | string | Subject, when not using a template. |
html / text | string | Raw bodies, when not using a template. Both are sent exactly as written — there is no render step on this path, so omitting text ships a message with an empty plain-text part. HTML→text derivation happens only for templates. |
templateSlug | string | Slug of a stored email template, rendered with variables. |
locale | string | Which locale of the template to render. Empty means "en", and a slug with no row for the locale you asked for falls back to its default row. |
| Param | Type | Description |
|---|---|---|
variables | Record<string, unknown> | Values substituted into the template. |
from | { email: string; name?: string } | Sender override — only an address your provider is allowed to send as. |
reply_to | string | Reply-To address. |
category | string | Message category (the preferences key). |
template | string | Deprecated. Forwarded as written and ignored by the stack — a send using it goes out with no template at all. Use templateSlug. |
Who the mail comes from
The sender is resolved at delivery, first match wins:
fromon the request — the caller wins, full stop.- The stack's default sender, a single
default_from_email/default_from_namesetting on the Environment, editable from Studio. This is how one app sends everything asAcme <hi@acme.com>without every call site saying so. - The configured provider's own sender, read out of its credentials — SendGrid and SES use their
from_domain, SMTP and ACS theirfrom_email.
If none of those produce an address, there is no sender and the send fails. There is no fourth tier on a hosted project: the platform-managed fallback exists in the code only for a deployment whose operator configured a shared provider, and a project on the Palbase cloud has none.
SMS — Notifications.sms.send(params)
import { Notifications } from "@palbase/backend";
// Raw body:
await Notifications.sms.send({ to: "+15551234567", body: "Your todo is due in 1 hour." });
// Or a stored template:
await Notifications.sms.send({
to: "+15551234567",
templateSlug: "due-reminder",
variables: { title: "Ship the docs" },
});
| Param | Type | Description |
|---|---|---|
to | string | string[] | Recipient number(s). |
body | string | Message text. |
templateSlug | string | Stored SMS template slug, rendered with variables. |
locale | string | Which locale of the template to render. Falls back the same way email does. |
variables | Record<string, unknown> | Template variables. |
Note: Supply either
bodyortemplateSlug. The two are mutually exclusive at the server, which refuses a send carrying both or neither.categoryis accepted here too, as the preferences key.
Phone verification — Notifications.verifications
A one-time code over SMS, as a separate rail from sms.send because a verification carries no text of yours: the provider generates the code and the message.
import { Notifications } from "@palbase/backend";
declare const user: { id: string };
declare const submittedCode: string;
const { data: started } = await Notifications.verifications.start({
to: "+14155551212",
locale: "tr", // optional; empty uses the service default
user_id: user.id, // optional attribution in the message log
});
// started = { verification_sid, status, to, message_id? }
const { data: verdict } = await Notifications.verifications.check({
to: "+14155551212",
code: submittedCode,
});
if (verdict?.approved) { /* … */ }
Warning: The code is never returned to you, in either call. And
checktreats a wrong or expired code as a normal outcome:{ approved: false }with a successful HTTP status anderror === null. Branch ondata.approved, not onerror, or every wrong code will look like a success.
Both routes require a twilio provider on the Environment; with none, they answer provider_not_configured like any other send.
Inbox — in-app messages
The inbox is a per-user message feed stored on your own stack. It needs no provider, and inbox.send is the one inbox method a controller can call:
import { Notifications } from "@palbase/backend";
declare const userId: string;
await Notifications.inbox.send({
to: userId,
body: "Nice work — 'Ship the docs' is done.",
title: "Todo completed",
action_url: "myapp://todos/done",
category: "activity",
});
send takes { to, body, title?, data?, action_url?, category?, channels?, push_deep_link? } — body is the only required field besides to — and resolves { message_id?, skipped? }, or a per-channel response when channels is set. Fanning out from a push with channels: ["push", "inbox"] writes the same row.
What a controller cannot call
Several methods on this client exist for the client SDKs and cannot work from a backend handler. The reason is structural rather than a missing permission: the module transport authenticates with the service-role key and no Authorization header, so the stack mints an identity with role=service_role and no subject — and each of these handlers reads the caller's user id and refuses when it is empty.
| Method | From a controller |
|---|---|
inbox.list, inbox.unreadCount, inbox.markRead, inbox.markAllRead, inbox.archive | 401 Authentication required |
preferences.get, preferences.update | 401 Authentication required |
registerDevice, unregisterDevice | 401 Authentication required |
They are typed on Notifications because one client type covers both sides of the wire. Read a user's inbox, edit their preferences and register their push token from the app, where a signed-in session exists. Preferences are still meaningful to you server-side even though you cannot read them: they are a channel × category matrix, and a send whose category a user disabled on a channel comes back skipped for that channel.
Templates
A template is a row on the stack: a slug, a locale, a subject and body with {{mustache}} placeholders, and a list of variables it requires. There is no file and no deploy step: nothing reads config/notifications.ts, it travels in no artifact, and a leftover copy on disk is ignored. A template exists because something created it through the API below or through Studio, and an edit is permanent rather than being overwritten by the next push.
import { Notifications } from "@palbase/backend";
await Notifications.templates.email.create({
slug: "todo-digest",
subject: "Your week in todos, {{name}}",
htmlBody: "<h1>Hi {{name}}</h1><p>You have {{openCount}} open todos.</p>",
variables: ["name", "openCount"],
});
| Method | Email input | SMS input |
|---|---|---|
templates.email.list() / templates.sms.list() | — | — |
…get(id) | — | — |
…create(input) | { slug, locale?, subject, htmlBody, textBody?, variables? } | { slug, locale?, body, variables? } |
…update(id, input) — patch semantics | { subject?, htmlBody?, textBody?, variables? } | { body?, variables? } |
…delete(id) | — | — |
A template view carries id, slug, locale, variables, isDefault, createdAt and updatedAt, plus subject / htmlBody / textBody for email and body for SMS. The client maps those from the wire's snake_case for you.
Rendering facts worth knowing before you write one:
variablesis a requirement, not documentation. Every name in the list must be present in thevariablesof the send, or the render is refused with400 missing required template variables: <names>and nothing is sent.- Bodies are Handlebars.
{{name}}substitutes; the usual block and helper syntax renders too, and a template that fails to render is a400naming which part failed —subject,html_bodyortext_body. textBodyis optional. Leave it out and the plain-text part is generated from the rendered HTML.- A slug must match
[a-z0-9][a-z0-9_-]{0,63}, andsubjectandhtmlBodyare both required on create. - Locales are rows, not files. One
(slug, locale)row per language;localeon the send picks one, an empty locale meansen, and a locale with no row falls back to the slug's default row.
Send caps
Sends are capped as an operator spend guard, not as a plan feature:
- 100 emails and 50 SMS per project per calendar month by default, counted at request time and incremented only when a send is allowed.
- Over the cap, the send is answered
429withRetry-After, and the counter is not moved. - Push and inbox are never capped.
- An operator can raise, lower or disable either cap with
PALNOTIFY_SEND_CAPS__EMAILandPALNOTIFY_SEND_CAPS__SMS; zero or a negative number disables that channel's cap.
There is no header or endpoint that tells you how much of the month's allowance is left, so a 429 from this surface arrives without warning. If you send close to the cap, count your own sends.
Field names on the wire
The client rewrites exactly three fields and forwards everything else as written:
| You write | The stack reads |
|---|---|
templateSlug | template_slug |
html | html_body |
text | text_body |
That mapping is load-bearing rather than cosmetic: forwarding html verbatim once produced a 400 about a field the caller had supplied. The corollary is that writing html_body yourself is wrong — pass html, and let the client rename it.
Configuring providers
Providers live on the Environment. Configure one from a linked checkout:
palbase notifications providers # the catalogue, marking what this stack has
palbase notifications add sendgrid --from-domain mail.acme.com
palbase notifications remove sendgrid
| Provider | Channel | Required settings | Credential |
|---|---|---|---|
apns | push | teamId, keyId, bundleId (optional production flag) | the .p8 key |
fcm | push | — | the service-account JSON |
sendgrid | fromDomain | the API key | |
ses | region, accessKeyId, fromDomain | the secret access key | |
smtp | host, port, fromEmail (optional username, STARTTLS) | the password |
acs (email) needs fromEmail, optionally fromName, and a connection string. twilio (sms) needs accountSid plus exactly one of fromNumber or messagingServiceSid, and an auth token. The exact CLI flags for each are on Stack Settings.
The credential itself goes to the Environment's vault under a reserved name derived as PB_NOTIFICATIONS_<PROVIDER>_<FIELD> — PB_NOTIFICATIONS_APNS_P8, PB_NOTIFICATIONS_FCM_SERVICE_ACCOUNT, PB_NOTIFICATIONS_SENDGRID_API_KEY — encrypted, and never read back by any listing.
Warning: That namespace is reserved by convention only. Nothing in the CLI or the stack refuses
palbase secret set PB_NOTIFICATIONS_SENDGRID_API_KEY=…, so a hand-set value silently overwrites whatnotifications addwrote and breaks delivery in a way that looks like a provider outage. LeavePB_NOTIFICATIONS_*topalbase notifications.
Warning:
palbase notifications remove <provider>deletes the sender and delivery on that channel stops immediately. There is no confirmation prompt and no deploy to wait for.
Removing a provider does not delete its vault entry, and configuring one does not travel with a push in either direction: settings reach a stack from whoever changes them, and code reaches it from palbase push. See Stack Settings.
Related
- Stack Settings —
palbase notifications, and the rest of the settings that live on the stack - Secrets — the vault a provider credential lands in
- Scheduled Jobs — sending a digest on a schedule
- Event Hooks — notifying on an event this stack raised
- Authentication — the auth flows that send their own mail
- Overview — the ambient platform services