Guide12 min read

Build a Multi-Tenant AI Email Marketing Platform (Where Each Customer's Plan Is Enforced at Send Time)

Build a multi-tenant AI email-marketing SaaS on Naïve: an agent drafts and sends from each customer's own inbox, with plan quotas enforced at send time.

Guide
TL;DR
  • A multi-tenant email-marketing tool breaks the moment each customer needs their own sending identity and their own plan limit you'd have to build per-tenant inboxes, a metering pipeline, and quota enforcement before writing any product
  • Naïve ships those as primitives: a tenant user per customer, an Account Kit that defines policy, an identity-aware inbox on your domain, and Customer Billing that maps a plan → kit → usage quotas
  • Every call is scoped with naive.forUser(customerId) and filtered by that customer's Account Kit Customer A's agent can never read or send from Customer B's inbox
  • The agent drafts campaigns with naive.llm.chat(), a full OpenRouter wrapper over 300+ models (anthropic/claude-sonnet-4.6, openai/gpt-5.2), billed in credits
  • email is a metered primitive: when a tenant exceeds the plan's quota, the send returns 429 rate_limited enforced server-side at execution time, not by your billing code
  • Upgrading a Stripe subscription flips the plan in one call (setSubscription), which re-assigns the Account Kit and lifts the quota instantly

Most "AI email marketing" demos are a single API key, a prompt, and a hardcoded SMTP sender. That works for one person. The moment you try to sell it as a product, the model isn't the hard part — identity and metering are:

  • Each customer's agent must send from that customer's own address, so replies and deliverability stay theirs — not a shared relay.
  • One customer's agent must never read or send from another customer's inbox.
  • Each customer's plan limit has to be enforced by the system, not trusted to an agent that will happily blast 50,000 emails on a Free plan if the prompt slips.

Without a platform you end up building per-tenant inboxes with DKIM/SPF/DMARC, a usage-metering pipeline, and a quota-enforcement layer before you write a single line of product. That plumbing is the product — and it's exactly what Naïve ships as primitives.

This is a developer tutorial. Every code block runs against the current @usenaive-sdk/server, with signatures pulled straight from the docs. By the end you'll have a multi-tenant agentic email-marketing backend where each customer gets an isolated, governed agent that is gated by policy and quota enforcement so it does not exceed its plan.

Per-customer architecture: each customer is an isolated tenant user governed by an Account Kit whose policy comes from their plan; the agent is scoped with naive.forUser(customer), drafts with the LLM primitive over OpenRouter, and sends from the customer's own inbox — where every send is metered against the plan quota and returns 429 rate_limited when exceeded.

What we're building

  • A backend service that provisions an isolated AI email agent per customer.
  • Two plans — Free (100 emails/month) and Pro (25,000 emails/month) — defined once as Account Kits plus usage quotas.
  • Each customer gets their own identity-aware inbox on your domain (e.g. acme@you.usenaive.ai).
  • The agent drafts a campaign with an OpenRouter model, then sends it from the customer's inbox.
  • Sends are metered — when a customer hits their plan quota, the API returns 429 rate_limited at send time.
  • Upgrading the customer's Stripe subscription flips the plan and lifts the quota in one call.

The primitives in play, all from the docs:

PrimitiveRole in the platformDocs
UsersOne tenant user per customer — the isolation boundarynaive.users
Account KitsThe plan's policy: which primitives are enablednaive.accountKits
Customer BillingPlans that map a key → Account Kit → usage quotasnaive.plans, forUser(id).billing
EmailPer-customer identity-aware inbox on your domainforUser(id).email
LLMOpenRouter-backed drafting across 300+ modelsforUser(id).llm
WebhooksInbound replies & unsubscribes via email.receivednaive.webhooks

Step 0: Install and authenticate

You need a Naïve workspace key (nv_sk_...) from Studio → Settings → API keys. The SDK is server-only — the key is a server secret, never shipped to the browser.

npm install @usenaive-sdk/server
import { Naive } from "@usenaive-sdk/server";
 
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });
  • naive.* (the root client) acts on your default tenant user and exposes the control plane (naive.users, naive.accountKits, naive.plans).
  • naive.forUser(id).* returns the same data-plane surface bound to a specific customer (email, llm, billing).

That forUser scoping is the whole game. Read it as: "do this, as this customer, under this customer's plan."

Step 1: Define the plans once — as Account Kits + quotas

A plan in Customer Billing maps a key to an Account Kit (what the tenant may do) plus quotas (how much, per period). Define the policy first.

Each kit just needs the email and llm primitives enabled — that's all an email agent uses:

const freeKit = await naive.accountKits.create({
  name: "Email — Free",
  primitives_config: {
    email: { enabled: true },
    llm: { enabled: true },
  },
});
 
const proKit = await naive.accountKits.create({
  name: "Email — Pro",
  primitives_config: {
    email: { enabled: true },
    llm: { enabled: true },
  },
});

Now map each plan key to its kit and its monthly send quota. email is one of the metered primitives (seo, aeo, email), so the number you put here is the hard ceiling Naïve enforces:

await naive.plans.upsert({
  key: "free",
  name: "Free",
  accountKitId: freeKit.id,
  quotas: { email: 100 },        // 100 sends / period
  period: "month",
});
 
await naive.plans.upsert({
  key: "pro",
  name: "Pro",
  accountKitId: proKit.id,
  stripePriceId: "price_123",    // your Stripe price for the Pro plan
  quotas: { email: 25000 },      // 25k sends / period
  period: "month",
});
 
const { plans } = await naive.plans.list();
  • The quota is per primitive, per period{ email: 100 } means 100 successful sends this month.
  • stripePriceId is optional and only used to tie the plan to your Stripe price; your app still owns the actual Stripe charge.
  • Metering and quota enforcement are free — they add no credit cost on top of the sends themselves.

Step 2: Provision a customer and subscribe them to a plan

When your app signs up a new customer, create a tenant user for them. Pass your own DB id as external_id so you can map back later:

const acme = await naive.users.create({
  external_id: "acme_db_uuid",
  email: "ops@acme.com",
  label: "Acme Corp",
});

Then give them a subscription. setSubscription with assignKit (default true) also applies the plan's Account Kit to the tenant in the same call — so subscribing is the single act that grants both the permissions and the quota:

// Seed every new signup on Free so they're metered from day one
await naive.forUser(acme.id).billing.setSubscription({
  planKey: "free",
  status: "active",
});

Note: Tenants without a subscription are not quota-limited. Seed a free-plan subscription on signup if you want every customer metered — otherwise an unsubscribed tenant can send unbounded.

You can read the rollup at any time:

const usage = await naive.forUser(acme.id).billing.usage();
// { plan: "free", status: "active", period: "month",
//   quotas: { email: 100 }, usage: { email: 0 } }

Step 3: Give the customer an identity-aware inbox

Email is one of the most fundamental primitives in Naïve. Every company gets a system domain auto-provisioned on registration, and inboxes are created on it. First confirm the domain is active (it may start pending_dns until the provider records verify):

const res = await fetch("https://api.usenaive.ai/v1/domains", {
  headers: { Authorization: `Bearer ${process.env.NAIVE_API_KEY}` },
});
const { domains } = await res.json();
// → [{ id, domain: "you.usenaive.ai", status: "active" }]

Now provision the customer's own sending identity. Each inbox is scoped to the tenant user and is the only address that customer's agent can send from:

const inbox = await naive.forUser(acme.id).email.createInbox({
  local_part: "acme",
});
// → { id: "inbox-uuid", address: "acme@you.usenaive.ai", status: "active" }
  • Replies land back in this inbox, so deliverability and reputation stay per-customer.
  • DKIM, SPF, and DMARC are configured automatically for the domain — you don't touch DNS for the system domain.
  • A customer can have several inboxes (acme@, acme-news@); the agent can only send from inboxes it owns.

Step 4: Draft the campaign with an OpenRouter model

The llm primitive is a full wrapper over OpenRouter — one OpenAI-compatible endpoint over 300+ models, with the request/response bodies passed through exactly as OpenRouter defines them. You don't manage an OpenRouter key; Naïve holds it and bills each call in credits.

OpenRouter routes the draft call across 300+ models behind one Naïve endpoint.

Because the body is OpenRouter's, you get its controls for free — a models[] fallback chain, a provider{} routing object, and response_format for structured output. Here the agent returns a subject line and body as strict JSON:

const draft = await naive.forUser(acme.id).llm.chat({
  model: "anthropic/claude-sonnet-4.6",
  models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.2"], // fallback chain
  messages: [
    {
      role: "system",
      content:
        "You write concise B2B marketing emails. Return JSON only: " +
        '{ "subject": string, "body": string }. Body is plain text, under 120 words.',
    },
    {
      role: "user",
      content:
        "Audience: ops leaders at mid-market logistics firms. " +
        "Goal: book a demo of our route-optimization tool. One clear CTA.",
    },
  ],
  response_format: { type: "json_object" }, // forwarded to OpenRouter as-is
});
 
const { subject, body } = JSON.parse(draft.choices[0].message.content);
console.log("credits:", draft.credits_used);
  • response_format, temperature, tools, stream and the rest are forwarded straight to OpenRouter — see their API reference.
  • The typed forUser(id).llm route is Account-Kit gated and per-customer, so a customer whose kit disables llm gets forbidden instead of a draft.
  • LLM cost is billed from OpenRouter's returned usage.cost ($0.50 = 1 credit) and is separate from the email send quota.

Step 5: Send from the customer's own inbox — metered automatically

Sending requires the inbox UUID you're sending from. This enforces identity: the agent can only send from an address it owns. Loop the campaign over the recipient list:

const recipients = ["lead1@corp.com", "lead2@corp.com" /* … */];
 
for (const to of recipients) {
  const sent = await naive.forUser(acme.id).email.send({
    from_inbox: inbox.id,
    to,
    subject,
    body,
  });
  // → { id: "msg-uuid", status: "sent", credits_used: 1 }
}

Every successful send costs 1 credit and — because email is a metered primitive — records one usage event against Acme's plan quota for the period. You didn't write any metering code; subscribing them in Step 2 is what turned it on.

Step 6: The plan ceiling is enforced at send time

This is the part you can't fake with a prompt. Once Acme has sent 100 emails this month on the Free plan, the 101st send does not go out — the API returns 429 rate_limited before anything is delivered.

Execution-time enforcement: one send call has two outcomes. Under quota, the mail is delivered from the customer's inbox and a usage event is recorded. Over quota, the API returns 429 rate_limited with the used/limit counts and nothing is sent.

Every SDK method throws a typed NaiveError on a non-2xx response, so you handle the ceiling explicitly:

import { Naive, NaiveError } from "@usenaive-sdk/server";
 
try {
  await naive.forUser(acme.id).email.send({ from_inbox: inbox.id, to, subject, body });
} catch (err) {
  if (err instanceof NaiveError && err.status === 429 && err.code === "rate_limited") {
    // Plan quota exceeded — stop the run and prompt an upgrade.
    // Nothing was sent and no credit was charged for this call.
    await promptUpgrade(acme.id);
  } else {
    throw err;
  }
}

The raw error body carries the counts so you can show them in your UI:

{ "error": { "code": "rate_limited",
  "message": "Plan quota exceeded for 'email' (100/100 this period)",
  "used": 100, "limit": 100 } }

The enforcement lives in the API, not in your loop and not in the model's system prompt. A jailbroken agent, a buggy retry, or a customer pasting 10,000 addresses all hit the same wall: usage.email vs plan.quotas.email, checked on every call.

Step 7: An upgrade lifts the quota instantly

Your Stripe webhook is the trigger. When Acme upgrades to Pro, your checkout.session.completed / customer.subscription.updated handler calls setSubscription with the new plan key. Because assignKit defaults to true, the same call re-assigns the Pro Account Kit — lifting both permissions and the quota on the next send:

// Inside your verified Stripe webhook handler
await naive.forUser(acme.id).billing.setSubscription({
  planKey: "pro",
  status: "active",
  stripeCustomerId: event.data.object.customer,        // "cus_123"
  stripeSubscriptionId: event.data.object.subscription, // "sub_123"
  currentPeriodEnd: "2026-07-01T00:00:00Z",
});
 
const usage = await naive.forUser(acme.id).billing.usage();
// → { plan: "pro", quotas: { email: 25000 }, usage: { email: 100 } }
  • No redeploy, no per-tenant config edit — the policy change is one API call.
  • Downgrades work the same way: set planKey: "free" (or status: "canceled") and the lower ceiling applies immediately.
  • The Free and Pro paths are the same code — only the subscribed plan differs.

Step 8: Close the loop — replies, unsubscribes, and consent

A real campaign generates replies and unsubscribe requests, and you must honor them. Before you send at scale, collect consent the way your counsel expects (CAN-SPAM in the US requires a valid physical address, a clear way to opt out, and honoring unsubscribes within 10 business days — and many regions add stricter consent rules). Naïve handles delivery and inbound events; you own list consent and suppression.

Subscribe to the email.received event so an inbound message resumes your agent. The subscription secret is shown once — store it and use that same value when verifying signatures:

const sub = await naive.webhooks.create(
  "https://app.example.com/api/webhooks/naive",
  ["email.received"],
);
// persist sub.secret — e.g. process.env.NAIVE_WEBHOOK_SECRET = sub.secret

Verify the signature with the stored subscription secret, then read the message and act — here, suppressing anyone who replies "unsubscribe":

import { verifyWebhookSignature } from "@usenaive-sdk/server";
 
const WEBHOOK_SECRET = process.env.NAIVE_WEBHOOK_SECRET!; // the sub.secret you saved above
 
export async function POST(req: Request) {
  const raw = await req.text();
  const sig = req.headers.get("x-naive-signature") ?? "";
  if (!verifyWebhookSignature(WEBHOOK_SECRET, raw, sig)) {
    return new Response("bad signature", { status: 401 });
  }
 
  const { event, data } = JSON.parse(raw); // data.tenant_user_id = the customer
  if (event === "email.received") {
    const full = await naive.forUser(data.tenant_user_id).email.getEmail(data.email_id);
    if (/unsubscribe/i.test(full.body ?? full.snippet ?? "")) {
      await suppress(data.tenant_user_id, full.from);
    }
  }
  return new Response("ok");
}

Deliveries are HMAC-signed and retried (3 attempts), and data.tenant_user_id keeps every inbound event scoped to the right customer.

Going further: a customer's own domain or ESP is approval-gated

The system domain works out of the box. If a customer wants to send from their own domain or connect their own ESP/CRM (HubSpot, Mailchimp, a custom SMTP via Connections), that's a higher-trust action — and connecting a third-party service is approval-gated by default for tenant users.

A gated call doesn't throw; it returns HTTP 202 with a pending status you discriminate with isPendingApproval:

import { isPendingApproval } from "@usenaive-sdk/server";
 
const conn = await naive.forUser(acme.id).connections.connect("mailchimp");
if (isPendingApproval(conn)) {
  // Frozen until a human approves it in the Approvals queue (conn.approval_id).
  // The action replays automatically on approval — see /docs/getting-started/approvals
} else {
  // redirect the customer to conn.redirectUrl to finish OAuth
}

You get two independent layers of execution-time enforcement on the same product: quota (429 on every metered send) and approval (202 on connecting new authority). Both live in the API, not in a prompt.

What Naïve handles for you

WhatHow it worksCost
Per-customer isolationOne tenant user per customer; every call scoped by forUser() and filtered by the kitFree
Plan → policy → quotanaive.plans.upsert maps a key → Account Kit → usage quotasFree
Quota enforcementMetered email sends return 429 rate_limited at the ceilingFree
Identity-aware inboxesPer-customer addresses on your domain, DKIM/SPF/DMARC configuredFree to create
Sendingemail.send from the customer's own inbox1 credit / email
Draftingllm.chat over OpenRouter, 300+ modelsOpenRouter cost ($0.50 = 1 credit)
Inbound repliesemail.received webhook, HMAC-signed and retriedFree
Subscriptionsbilling.setSubscription flips plan + Account Kit in one callFree

Costs

  • Metering and quota enforcement: free — no credit cost beyond the sends themselves.
  • Each email send: 1 credit, deducted on success (a 429 charges nothing).
  • Each draft: the exact OpenRouter token cost, converted at $0.50 = 1 credit.
  • Inbound webhooks and inbox creation: free.
  • See usenaive.ai/docs/getting-started/credits for current rates.

Get started

  1. Get a key at studio.usenaive.ai → Settings → API keys
  2. Define your plans — create Account Kits and naive.plans.upsert with email quotas
  3. Provision customersnaive.users.create + billing.setSubscription
  4. Give each an inboxforUser(id).email.createInbox
  5. Draft and sendforUser(id).llm.chat then forUser(id).email.send
  6. Wire Stripe — flip plans on your subscription webhook

The platform isolates each customer, drafts and sends from their own identity, and stops them at their plan's ceiling. You write the product.

Frequently Asked Questions
Why can't I just build a multi-tenant email tool with SMTP and an LLM?+
You can wire up SMTP and a prompt in an afternoon — that's a personal script. Selling it to many customers is where the work is: each customer needs their own authenticated sending identity (so replies and deliverability stay theirs), strict isolation so one customer's agent can never send from another's inbox, and a plan limit that the system actually enforces rather than trusts the agent to respect. Naïve ships those as primitives: tenant users isolate each customer, the Email primitive provisions identity-aware inboxes on your domain with DKIM/SPF/DMARC handled, and Customer Billing maps a plan to an Account Kit plus usage quotas that return 429 when exceeded.
How does Naïve isolate one customer from another?+
Every primitive — email, vault, connections, llm — is scoped to a tenant user. You create one tenant user per customer with naive.users.create(), then make every call through naive.forUser(customerId). That scoped client can only see and act on that customer's resources, and each call is additionally filtered by the customer's Account Kit. There is no path for Customer A's scoped client to reach Customer B's inbox, drafts, or usage.
What exactly is enforced 'at send time'?+
The plan quota. In Customer Billing, email is a metered primitive: each successful send records one usage event against the tenant's plan quota for the current period. When the tenant exceeds it, the next send returns 429 rate_limited with the used/limit counts instead of sending — enforced by the API, server-side. The agent cannot raise its own ceiling; only a plan change (a new subscription) does. This is execution-time permission enforcement in its most literal form.
Which models can the agent use, and how is it billed?+
The llm primitive is a full wrapper over OpenRouter, so you get 300+ models across Anthropic, OpenAI, Google, Meta, and Mistral through one OpenAI-compatible endpoint — e.g. anthropic/claude-sonnet-4.6 or openai/gpt-5.2, with optional models[] fallback chains and a provider{} routing object. The request/response bodies are exactly OpenRouter's. Naïve holds the OpenRouter key and bills the exact cost OpenRouter reports, converted to credits ($0.50 = 1 credit). LLM usage is separate from the email send quota.
Do my customers ever sign into Naïve?+
No. Tenant users are not auth subjects — they never sign in, and you manage them entirely through the API/SDK/CLI. The customer signs into your product; your backend uses one Naïve workspace key (nv_sk_...) and scopes calls per customer with naive.forUser(id). Your app still owns the Stripe charge and the login — Naïve owns plan → permissions → quota → usage.
How do upgrades and downgrades work with Stripe?+
Your Stripe webhook is the trigger. On a successful checkout or subscription update, call naive.forUser(customerId).billing.setSubscription({ planKey, status, stripeCustomerId, stripeSubscriptionId, currentPeriodEnd }). With assignKit defaulting to true, that same call re-assigns the plan's Account Kit to the tenant — so a Free → Pro upgrade lifts the quota and any expanded permissions in one step, on the next send. Metering and quota enforcement carry no extra credit cost.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading