Palbase
Sign inGet started

Backend SDK

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

ChannelWhat it needs
PushAPNs (apns) or FCM (fcm) — they authenticate as your app, so nothing else can send for you
Emailone of sendgrid, ses, smtp or acs
SMStwilio
Phone verificationtwilio — the same provider as SMS
Inboxnothing — 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)

ParamTypeDescription
tostring | string[] | { topic: string }One user id, several, or a topic.
title / bodystring | Record<string, string>Plain text, or a locale map (below).
variablesRecord<string, string>Template variables.
default_localestringFallback locale for a locale map.
dataRecord<string, string>Custom key/value payload delivered to the app.
ParamTypeDescription
imagestringImage URL.
badgenumberApp icon badge count.
soundstringNotification sound.
deep_linkstringLink opened when the notification is tapped.
collapse_keystringReplaces earlier undelivered notifications with the same key.
priority"high" | "normal"Delivery priority.
ttlnumberTime to live.
silent / content_availablebooleanBackground delivery with no alert; wake the app (iOS).
categorystringMessage category — also the key a user's preferences are matched on.
metadataunknownArbitrary metadata stored with the message.
channelsArray<"push" | "inbox">Fan the message out to several channels.
inbox_action_urlstringAction 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 },
});
ParamTypeDescription
tostring | string[]Recipient address(es).
subjectstringSubject, when not using a template.
html / textstringRaw 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.
templateSlugstringSlug of a stored email template, rendered with variables.
localestringWhich 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.
ParamTypeDescription
variablesRecord<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_tostringReply-To address.
categorystringMessage category (the preferences key).
templatestringDeprecated. 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:

  1. from on the request — the caller wins, full stop.
  2. The stack's default sender, a single default_from_email / default_from_name setting on the Environment, editable from Studio. This is how one app sends everything as Acme <hi@acme.com> without every call site saying so.
  3. The configured provider's own sender, read out of its credentials — SendGrid and SES use their from_domain, SMTP and ACS their from_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" },
});
ParamTypeDescription
tostring | string[]Recipient number(s).
bodystringMessage text.
templateSlugstringStored SMS template slug, rendered with variables.
localestringWhich locale of the template to render. Falls back the same way email does.
variablesRecord<string, unknown>Template variables.

Note: Supply either body or templateSlug. The two are mutually exclusive at the server, which refuses a send carrying both or neither. category is 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 check treats a wrong or expired code as a normal outcome: { approved: false } with a successful HTTP status and error === null. Branch on data.approved, not on error, 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.

MethodFrom a controller
inbox.list, inbox.unreadCount, inbox.markRead, inbox.markAllRead, inbox.archive401 Authentication required
preferences.get, preferences.update401 Authentication required
registerDevice, unregisterDevice401 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"],
});
MethodEmail inputSMS 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:

  • variables is a requirement, not documentation. Every name in the list must be present in the variables of the send, or the render is refused with 400 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 a 400 naming which part failed — subject, html_body or text_body.
  • textBody is 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}, and subject and htmlBody are both required on create.
  • Locales are rows, not files. One (slug, locale) row per language; locale on the send picks one, an empty locale means en, 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 429 with Retry-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__EMAIL and PALNOTIFY_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 writeThe stack reads
templateSlugtemplate_slug
htmlhtml_body
texttext_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
ProviderChannelRequired settingsCredential
apnspushteamId, keyId, bundleId (optional production flag)the .p8 key
fcmpushthe service-account JSON
sendgridemailfromDomainthe API key
sesemailregion, accessKeyId, fromDomainthe secret access key
smtpemailhost, 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 what notifications add wrote and breaks delivery in a way that looks like a provider outage. Leave PB_NOTIFICATIONS_* to palbase 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.

  • Stack Settingspalbase 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