Whitelabeling
Multi-tenant SaaS products send notifications on behalf of their customers. Acme Corp and Beta Inc both use your help-desk product, but Acme’s password-reset email should look like Acme — purple brand, Inter font — and Beta’s should look like Beta — orange, Charter, different voice.
Norq supports this with a per-call brand override: pass brand: to send() (or compile()) and the same template renders with that tenant’s visual identity. One project, one set of templates, N brands at runtime.
const acmeBrand = await loadBrandFor(tenantId); // from your tenant DB
await client.send("transactional/password-reset", {
to: { email: "alice@acme.com" },
data: { firstName: "Alice", resetUrl: "https://..." },
brand: acmeBrand, // ← runtime override per recipient
});How it works
brand is a partial brand — same shape as brand.yaml, just the bits you want to swap. Your project’s brand stays the base; the per-call brand merges on top using the same semantics as the per-template frontmatter brand: block.
| Layer | Source | When |
|---|---|---|
| Built-in compiler defaults | EmailTheme::default() |
Always (cheapest fallback) |
Project brand.yaml |
Loaded once at new Norq(...) |
Cached on the client |
Per-call brand: opt |
Passed to send() / compile() |
This send only |
Template frontmatter brand: |
The template file itself | Compile time, per template |
Later layers win on per-field conflicts. Omitted fields inherit. There is no “reset to default” — pass an empty object to inherit everything from the project brand.
Shape of the override
The override accepts either a plain JS object or a YAML string. The object shape mirrors brand.yaml:
const acmeBrand = {
meta: { name: "Acme" },
tokens: {
colors: {
brand: "#6d28d9",
"brand-text": "#ffffff",
},
typography: {
body: { fontFamily: "Inter", fontSize: 16 },
},
},
styles: {
"button-primary": {
bg: "{colors.brand}",
color: "{colors.brand-text}",
},
},
modes: {
dark: {
colors: { brand: "#a78bfa" },
},
},
};YAML works identically:
const acmeBrandYaml = `
tokens:
colors:
brand: "#6d28d9"
modes:
dark:
colors:
brand: "#a78bfa"
`;
await client.send("transactional/welcome", {
to: { email: "alice@acme.com" },
brand: acmeBrandYaml,
});Pass YAML when your tenant DB stores brands as text; pass an object when you build them in-memory.
What’s overridable
| Section | Override path |
|---|---|
| Color tokens | tokens.colors.<name> (or shorthand colors.<name>) |
| Typography body | tokens.typography.body.fontFamily / .fontSize / .lineHeight |
| Code theme | tokens.codeTheme (or shorthand codeTheme) |
| Dark mode strategy | modes.colorMode |
| Dark color overrides | modes.dark.colors.<name> |
| Default attrs | defaults.<element>.<attr> |
| Styles | styles.<bundle>.<attr> |
The override surface mirrors the per-template frontmatter brand: block (see Brand → Per-template overrides).
What’s NOT overridable per send
element-typography— heading-tag → typography-token mapping is project-stable by designfontsblock — font delivery is a build-time concern (the email’s<head>carries the<link>tag); per-tenant font URLs need to live inbrand.yamlvoice— per-tenant voice belongs inAGENTS_BRAND.mdper projectmeta— informational only
If a tenant truly needs a different font family, override tokens.typography.body.fontFamily with a CSS stack — that updates the inline styles in the rendered email even though the @font-face injection stays project-level.
SDK reference — all five languages
Every SDK exposes a brand parameter on send() (and the public-equivalent compile path). Supported in Node, Python, Go, Java, Ruby.
Node / TypeScript
import { Norq } from "norq.sh";
const client = new Norq({ projectDir: "./notifications" });
await client.send("transactional/welcome", {
to: { email: "alice@acme.com" },
data: { firstName: "Alice" },
brand: {
tokens: { colors: { brand: "#6d28d9" } },
modes: { dark: { colors: { brand: "#a78bfa" } } },
},
});YAML string also accepted:
await client.send("transactional/welcome", {
to: { email: "alice@acme.com" },
brand: `
tokens:
colors:
brand: "#6d28d9"
`,
});Python
from norq import Norq
client = Norq(project_dir="./notifications")
client.send(
"transactional/welcome",
to={"email": "alice@acme.com"},
data={"firstName": "Alice"},
brand={
"tokens": {"colors": {"brand": "#6d28d9"}},
"modes": {"dark": {"colors": {"brand": "#a78bfa"}}},
},
)Go
import "github.com/suprsend/norq/sdks/go"
client, _ := norq.NewClient(ctx, norq.WithProjectDir("./notifications"))
defer client.Close(ctx)
result, err := client.Send(ctx, "transactional/welcome", norq.SendOpts{
To: &norq.Recipient{Email: "alice@acme.com"},
Data: map[string]any{"firstName": "Alice"},
Brand: map[string]any{
"tokens": map[string]any{
"colors": map[string]any{"brand": "#6d28d9"},
},
"modes": map[string]any{
"dark": map[string]any{
"colors": map[string]any{"brand": "#a78bfa"},
},
},
},
})A YAML/JSON string also works (Brand: yamlString).
Java
import sh.norq.Norq;
import sh.norq.Types.SendOpts;
import sh.norq.Types.Recipient;
import java.util.Map;
Norq client = Norq.builder().projectDir("./notifications").build();
SendOpts opts = new SendOpts()
.to(new Recipient().email("alice@acme.com"))
.data(Map.of("firstName", "Alice"))
.brand(Map.of(
"tokens", Map.of("colors", Map.of("brand", "#6d28d9")),
"modes", Map.of("dark", Map.of(
"colors", Map.of("brand", "#a78bfa")
))
));
client.send("transactional/welcome", opts);Ruby
require "norq"
client = Norq::Client.new(project_dir: "./notifications")
client.send(
"transactional/welcome",
to: { email: "alice@acme.com" },
data: { firstName: "Alice" },
brand: {
tokens: { colors: { brand: "#6d28d9" } },
modes: { dark: { colors: { brand: "#a78bfa" } } }
}
)Multi-tenant patterns
Pattern 1: Brand cached in your tenant DB
The most common shape. Each tenant row carries its brand as a JSON column or a YAML blob:
async function loadTenantBrand(tenantId: string): Promise<object> {
const row = await db.query("SELECT brand FROM tenants WHERE id = $1", [
tenantId,
]);
return row.brand; // already a JSON object
}
await client.send("transactional/password-reset", {
to: { email: user.email },
data: { firstName: user.firstName, resetUrl },
brand: await loadTenantBrand(user.tenantId),
});Pattern 2: One Norq client, hot brand swap
Single SDK instance, brand selected per request from the recipient context. Zero per-tenant memory cost — the SDK loads templates and the compiler once at construction.
const client = new Norq({ projectDir: "./notifications" });
app.post("/notify", async (req, res) => {
await client.send(req.body.notificationId, {
to: req.body.recipient,
data: req.body.data,
brand: req.user.tenant.brand,
});
res.sendStatus(202);
});Pattern 3: Tenant brand from a registry
If you publish brands as registry packages (one per tenant), import once at startup and pick at send time:
import * as fs from "node:fs/promises";
const BRAND_CACHE = new Map<string, string>();
async function brandYamlFor(tenantId: string): Promise<string> {
let yaml = BRAND_CACHE.get(tenantId);
if (!yaml) {
yaml = await fs.readFile(`./tenant-brands/${tenantId}/brand.yaml`, "utf8");
BRAND_CACHE.set(tenantId, yaml);
}
return yaml;
}
await client.send(notifId, {
to: recipient,
brand: await brandYamlFor(tenantId),
});Performance
The override path adds one extra in-memory merge per send() (or compile()) when brand is present. Calls without brand skip the helper entirely and reuse the project’s cached resolved brand — zero overhead for non-multi-tenant use.
The override cost itself is microseconds: a JSON deserialise + a small struct merge + a JSON serialise. The expensive bits (template parse, MJML render, partial expansion) are unaffected.
Authoring tenant brands
Brand authoring is the same as project brands — see the Brand page. The on-disk shape is brand.yaml, validated by schemas/norq-brand.schema.json.
A typical tenant brand only needs to override colors, dark colors, and maybe a font family:
# tenants/acme/brand.yaml
meta:
name: Acme
tokens:
colors:
brand: "#6d28d9"
background: "#ffffff"
typography:
body:
fontFamily: "Inter, -apple-system, sans-serif"
modes:
dark:
colors:
brand: "#a78bfa"Run norq brand show against a tenant directory to verify the resolved tokens before storing the brand in your DB.
Validation and gotchas
| Behaviour | What you get |
|---|---|
brand omitted |
Project’s default brand. Zero overhead. |
brand: "" (empty string) |
Project’s default brand. Treated as “no override”. |
brand: {} (empty object) |
Project’s default brand. Same as omitted. |
brand: "tokens:\n bad: yaml" |
Throws NorqError: brand override failed: ... with the parser’s diagnostic. |
Frontmatter brand: in the template |
Wins over the per-call brand for fields it specifies. Per-call brand fills the gaps. |
modes.dark overrides |
Apply only to email; non-email channels ignore. |
Architectural notes
Per-call brand override doesn’t reload the template, doesn’t re-parse the project, doesn’t touch the filesystem. It’s a single in-memory transform of the cached theme, applied right before the compile call. The compiler signature has always been compile(doc, data, theme) — multi-tenant just lets you build theme from per-call data.
The same code path is used by:
- The
frontmatter brand:block in templates (compile-time author override) - The
--brandCLI flag fornorq compile/norq send(developer flag for previewing brands) - The per-call
brand:opt in the SDK (runtime tenant override)
All three share EmailTheme::merge_frontmatter in crates/core/src/types.rs.
See also
- Brand — authoring
brand.yamland the full token system norq brandCLI — init / show / import / export / diff / sync-agents- Node SDK reference — full
send()/compile()opts