Guide11 min read

How to Build a Usage-Metered AI SEO SaaS

Build a multi-tenant AI SEO/AEO SaaS with Customer Billing: plans enforce quotas at execution time — 429 on overage, forbidden when a primitive is off-tier.

Read the docs →

Guide
TL;DR
  • A usage-metered SaaS is the textbook case for execution-time permissions: the tier a customer pays for must decide, at call time, what their agent can do and how much of it
  • This build wires a multi-tenant AI SEO/AEO product on Naïve where a plan maps to an Account Kit (which primitives are on) plus per-primitive quotas (how many calls per period)
  • The moat: you write zero usage counters and zero feature flags Naïve meters every metered call (`seo`, `aeo`, `email`) and returns `429 rate_limited` when a tenant exceeds quota, and `forbidden` when the tier never had that primitive
  • One `setSubscription({ planKey, assignKit })` call driven from your Stripe webhook — swaps a tenant's permissions and quotas atomically, so upgrades and downgrades take effect on the very next request
  • The exact same agent code runs for every customer; `naive.forUser(tenantId)` scopes usage, quota, and capability to that one tenant

Every SaaS that sells tiers has the same unglamorous back office: a plan enables some features, caps usage on others, and the moment a customer hits their limit the product has to say no — cleanly, at the exact call, for every customer at once.

That back office is exactly what an AI product cannot skip. An SEO agent that researches keywords is useful. An SEO agent that a free-tier customer can run ten thousand times, or that quietly calls a primitive the customer never paid for, is a billing incident. The tier has to be enforced where the work happens — at execution time — not by a hopeful if statement in your agent loop.

This tutorial builds a multi-tenant AI SEO/AEO SaaS on Naïve — the autonomous company infrastructure for governed agents — where that back office is a set of primitives instead of your code. It pairs with the AgentSEO keyword research loop and brand mention tracking use cases on the same /seo and /aeo primitives.

  • A plan maps to an Account Kit — the tier decides which primitives (seo, aeo, email) are even enabled.
  • A plan carries quotas — the tier decides how many calls per period each primitive gets.
  • Naïve meters and enforces — every metered call records usage; overruns return 429 rate_limited; disabled primitives return forbidden. You write neither the counter nor the check.

You could build the SEO agent itself in an afternoon. The part that makes it a product you can charge for — per-customer metering, tier-scoped capabilities, and enforcement that holds no matter what the agent code tries — is the part Naïve gives you as the Customer Billing primitive.

Usage-metered SEO SaaS architecture on Naïve

What you'll build

A product an agency or a marketing team subscribes to. Each customer is a tenant, and for each tenant the agent runs one job — a content brief for a target keyword:

  • Research the keyword with the seo primitive — search volume and SERP competitors.
  • Audit AI-search visibility with the aeo primitive — how ChatGPT answers the query and who it cites.
  • Compose a structured brief with the llm primitive (OpenRouter wrapper).
  • Deliver the brief by email with the email primitive — on tiers that include it.

The same code runs for every customer. What changes per customer is their plan, and the plan is enforced by Naïve:

PrimitiveRole in the buildMetered?Docs
/account-kitsCapability tier — which primitives a tenant may useAccount Kits
/customer-billingPlans (kit + quotas), subscriptions, usage rollupCustomer Billing
/seoKeyword volume + SERP competitor researchyes (seo)SEO
/aeoAI-search visibility (LLM responses, mentions)yes (aeo)GEO/AEO
/llmOpenRouter wrapper — compose the structured briefno (credits)LLM
/emailDeliver the brief to the customeryes (email)Email

Metered primitives are seo, aeo, and email — each records one usage event per successful call and returns 429 rate_limited when the tenant exceeds its plan quota. This is straight from the Customer Billing docs, not invented for this post.

Step 0: Install and authenticate

The whole build runs on the Naïve server SDK.

npm install @usenaive-sdk/server
import { Naive, NaiveError } from "@usenaive-sdk/server";
 
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });
  • Naive is the client. Control-plane calls (naive.accountKits, naive.plans) manage your product's tiers.
  • naive.forUser(tenantId) scopes every primitive call to one customer — this is how one code base serves every tenant, each with their own kit, quota, and usage.
  • NaiveError is how the moat surfaces in code: it carries status, code, and hint. You will branch on 429 rate_limited and forbidden.

Step 1: Define the tiers as Account Kits

The tier is a capability boundary before it is a price. On Naïve that boundary is an Account Kit — a policy template that decides which primitives are enabled. Define one kit per tier.

// FREE: keyword research only. aeo and email are OFF — not rate-limited, OFF.
const freeKit = await naive.accountKits.create({
  name: "SEO Free",
  primitives_config: {
    seo:   { enabled: true },
    aeo:   { enabled: false },
    email: { enabled: false },
  },
});
 
// GROWTH: research + AI-search audit. Still no outreach email.
const growthKit = await naive.accountKits.create({
  name: "SEO Growth",
  primitives_config: {
    seo:   { enabled: true },
    aeo:   { enabled: true },
    email: { enabled: false },
  },
});
 
// SCALE: the full brief + email delivery.
const scaleKit = await naive.accountKits.create({
  name: "SEO Scale",
  primitives_config: {
    seo:   { enabled: true },
    aeo:   { enabled: true },
    email: { enabled: true },
  },
});
  • primitives_config is the exact field from the Account Kits guide: a primitive slug maps to { enabled } (and optionally requiresApproval).
  • A primitive set to enabled: false is unreachable for any tenant on that kit. The agent code doesn't check the tier — the kit does, at execution time.

Step 2: Turn each kit into a plan with quotas

A kit says what; a plan adds how much. The Customer Billing primitive's plans map a plan key to an Account Kit plus per-primitive quotas.

await naive.plans.upsert({
  key: "free",
  name: "Free",
  accountKitId: freeKit.id,
  quotas: { seo: 50 },              // 50 keyword lookups / month, nothing else
  period: "month",
});
 
await naive.plans.upsert({
  key: "growth",
  name: "Growth",
  accountKitId: growthKit.id,
  stripePriceId: "price_growth_monthly", // your Stripe price
  quotas: { seo: 2000, aeo: 500 },
  period: "month",
});
 
await naive.plans.upsert({
  key: "scale",
  name: "Scale",
  accountKitId: scaleKit.id,
  stripePriceId: "price_scale_monthly",
  quotas: { seo: 20000, aeo: 5000, email: 2000 },
  period: "month",
});
 
const { plans } = await naive.plans.list();
  • quotas maps a primitive slug to its max calls per period (month or day) — verbatim from the Customer Billing overview.
  • accountKitId is the kit assigned to a tenant when they subscribe — so tier permissions and tier quotas live in one object.
  • stripePriceId is optional and is your Stripe price; Naïve owns plan → permissions → quota → usage, your app still owns the actual charge.

Plan maps to an Account Kit and per-primitive quotas

Step 3: Provision a tenant and subscribe them

Each customer is a tenant user. On signup, provision the user and seed a subscription — seeding a free-plan subscription is what makes every customer metered from call one.

// Your app just signed up "Northwind Marketing".
const tenant = await naive.users.create({
  external_id: "northwind_db_uuid",   // your own DB id
  email: "ops@northwind.example",
  label: "Northwind Marketing",
});
 
// Seed the free plan. assignKit (default true) assigns the plan's kit in the
// same call — permissions + quotas applied together.
await naive.forUser(tenant.id).billing.setSubscription({
  planKey: "free",
  status: "active",
});
  • setSubscription is the one call your Stripe webhook drives. Its assignKit defaults to true, so it also assigns the plan's Account Kit — one call applies tier permissions and quotas.
  • The tenant now has seo enabled with a 50-call/month quota, and aeo/email disabled. No feature flag in your database; the plan is the source of truth.

Step 4: The agent job — the same code for every tenant

Here is the whole job. Notice it does not ask what tier the tenant is on. It just calls primitives through naive.forUser(tenantId) and lets Naïve enforce the plan.

import { Naive, NaiveError } from "@usenaive-sdk/server";
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });
 
async function buildContentBrief(tenantId: string, keyword: string) {
  const t = naive.forUser(tenantId);
 
  // 1. SEO research — metered primitive `seo`.
  const volume = await t.seo.searchVolume([keyword]);
  const competitors = await t.seo.serpCompetitors([keyword]);
 
  // 2. AI-search audit — metered primitive `aeo`.
  const aiAnswer = await t.aeo.llmResponses("chatgpt", {
    user_prompt: `best ${keyword} 2026`,
    model_name: "gpt-4o-mini",
  });
 
  // 3. Compose a structured brief — llm (OpenRouter wrapper), billed in credits.
  const brief = await t.llm.chat({
    model: "anthropic/claude-sonnet-4.6",
    messages: [
      {
        role: "system",
        content: "You are an SEO strategist. Return a content brief as JSON.",
      },
      {
        role: "user",
        content: JSON.stringify({ keyword, volume, competitors, aiAnswer }),
      },
    ],
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "content_brief",
        strict: true,
        schema: {
          type: "object",
          properties: {
            title: { type: "string" },
            target_keyword: { type: "string" },
            search_volume: { type: "number" },
            competing_domains: { type: "array", items: { type: "string" } },
            ai_cited_domains: { type: "array", items: { type: "string" } },
            outline: { type: "array", items: { type: "string" } },
          },
          required: [
            "title", "target_keyword", "search_volume",
            "competing_domains", "ai_cited_domains", "outline",
          ],
          additionalProperties: false,
        },
      },
    },
  });
 
  // response_format json_schema => content is a JSON string. Parse it.
  const parsed = JSON.parse(brief.choices[0].message.content);
  return { brief: parsed, cost: brief.credits_used };
}
  • seo.searchVolume / seo.serpCompetitors and aeo.llmResponses("chatgpt", { user_prompt, model_name }) are the exact signatures from the SEO and AEO sub-clients.
  • llm.chat forwards the body verbatim to OpenRouter. The model slug (anthropic/claude-sonnet-4.6) and the response_format: { type: "json_schema", ... } block are OpenRouter's own shapes — see the llm sub-client. With a strict JSON schema, message.content comes back as a JSON string, so you JSON.parse it.
  • brief.credits_used is what the OpenRouter call cost in Naïve credits — the number you price your LLM usage against.

Step 5: Watch the plan enforce itself

This is the payoff. The moat is not a claim in the marketing copy — it is two error codes your agent code never raises.

Execution-time enforcement: forbidden and 429 rate_limited

A free tenant hits a feature it never paid for

buildContentBrief calls aeo.llmResponses. The free tenant's kit has aeo disabled, so the call throws before it runs — no partial work, no AI-search credits spent.

try {
  await buildContentBrief(freeTenant.id, "project management software");
} catch (err) {
  if (err instanceof NaiveError && err.code === "forbidden") {
    // err.status === 403, err.code === "forbidden" (primitive_disabled_by_kit)
    // Prompt the customer to upgrade — the tier, not your code, said no.
    console.log("aeo not on this plan:", err.hint);
  }
}
  • forbidden with reason primitive_disabled_by_kit is the documented code for calling a primitive the tenant's Account Kit disables (Errors).

A growth tenant burns through its quota

The growth tenant can call seo, but only 2000 times this month. On call 2001, Naïve returns 429.

try {
  await naive.forUser(growthTenant.id).seo.searchVolume(["crm software"]);
} catch (err) {
  if (err instanceof NaiveError && err.status === 429 && err.code === "rate_limited") {
    // The 429 body carries the counters, e.g.
    // { code: "rate_limited",
    //   message: "Plan quota exceeded for 'seo' (2000/2000 this period)",
    //   used: 2000, limit: 2000 }
    console.log("seo quota exhausted — upsell or wait for period reset");
  }
}
  • 429 rate_limited on quota overrun is documented in Customer Billing; the response body reports used and limit. You never wrote a counter — Naïve recorded one usage event per successful seo call and rejected the one that crossed the line.

Read the meter any time

Render a usage bar in your dashboard straight from the rollup — no aggregation query on your side.

const usage = await naive.forUser(growthTenant.id).billing.usage();
// {
//   plan: "growth", status: "active", period: "month",
//   quotas: { seo: 2000, aeo: 500 },
//   usage:  { seo: 2000, aeo: 143 }
// }

Step 6: Upgrades and downgrades from your Stripe webhook

The customer clicks Upgrade to Scale; Stripe fires customer.subscription.updated. Your webhook makes one call, and the tenant's capability and quota change on their next request.

// inside your POST /webhooks/stripe handler, after verifying the signature
export async function onStripeSubscriptionUpdated(event: StripeSubscriptionEvent) {
  const tenantId = event.metadata.naive_user_id; // you stored this on subscribe
  const planKey = event.metadata.plan_key;       // "growth" | "scale" | "free"
 
  await naive.forUser(tenantId).billing.setSubscription({
    planKey,
    status: "active",
    stripeCustomerId: event.customer,
    stripeSubscriptionId: event.subscription,
    currentPeriodEnd: event.current_period_end, // ISO string
    assignKit: true, // swap the Account Kit atomically with the subscription
  });
}
  • Because enforcement is read at call time, the upgrade is live immediately: the previously-forbidden aeo and email primitives now succeed, and quotas jump to the Scale numbers.
  • A downgrade is the identical call with a smaller planKey — it re-gates the primitives and lowers the ceilings. No migration, no flag flip, no cache to bust.

Deliver the brief on the tiers that include it

Now that a Scale tenant has email enabled, the final step of the job runs for them and stays forbidden for everyone else — same code.

async function deliverBrief(tenantId: string, to: string, brief: unknown) {
  const t = naive.forUser(tenantId);
  try {
    const inbox = await t.email.createInbox({ local_part: "briefs" });
    await t.email.send({
      from_inbox: inbox.id,
      to,
      subject: "Your content brief is ready",
      body: JSON.stringify(brief, null, 2),
    });
  } catch (err) {
    if (err instanceof NaiveError && err.code === "forbidden") {
      // email isn't on this tenant's tier — deliver in-app instead.
    } else {
      throw err;
    }
  }
}
  • email.createInbox({ local_part }) and email.send({ from_inbox, to, subject, body }) are the exact email sub-client signatures, and email is the third metered primitive — every send counts against the tenant's email quota.

Why this needs the moat

Strip Naïve out and rebuild the same product on raw provider keys — a DataForSEO key, an LLM key, an email key — and the back office comes back:

  • A usage table keyed by (customer, primitive, period), incremented on every call, read on every call.
  • Feature flags per customer per feature, checked before each call and kept in sync with billing state.
  • A race-free reject at the quota edge so two concurrent calls can't both slip past 1999 → 2001.
  • A migration every time a customer upgrades, to flip the flags and reset or reproject the counters.

That is the code you delete by using Customer Billing. Naïve makes plan → permissions → quota → usage a property of the tenant, enforced server-side, so:

  • The agent job is a straight line — no tier branches, no counters.
  • The tier is enforced at the primitive, so a bug in your loop can't over-serve a customer.
  • Upgrades are one setSubscription call and take effect on the next request.
  • Each tenant's usage, quota, and capability are isolated by forUser(id) — one code base, every customer governed independently.

You demonstrate the moat by trying to break it: the free tenant's aeo call returns forbidden, the growth tenant's 2001st seo call returns 429, and neither line of enforcement lives in your repo.

Get started

  1. Install the SDKnpm install @usenaive-sdk/server and set NAIVE_API_KEY.
  2. Create one Account Kit per tier — enable/disable seo, aeo, email per the tier's features.
  3. Upsert one plan per tier — map each accountKitId to its quotas and period.
  4. Subscribe tenants from your Stripe webhooksetSubscription({ planKey, assignKit: true }).
  5. Run the job with forUser(tenantId) — let forbidden and 429 rate_limited do the enforcing.
Frequently Asked Questions
What is a usage-metered SaaS and why is it hard to build?+
It is a product where customers pay for a tier that both enables features and caps how much they can use. The hard part is not the Stripe charge — it is enforcing the tier at runtime across every code path: gating features the tier doesn't include, counting usage per customer per period, and rejecting calls once a quota is hit. Rolled by hand, that means a usage table, per-primitive counters, feature flags, and a race-free way to reject at the limit — for every customer. Naïve's Customer Billing primitive makes plan → permissions → quota → usage a platform concern, so the enforcement is server-side and your agent code stays a straight line.
How does Naïve enforce a plan at execution time?+
A plan maps a key to an Account Kit and a set of per-primitive quotas. Subscribing a tenant assigns the kit (which enables/disables primitives) and activates the quotas in one call. From then on, every metered call the tenant makes records one usage event; when the count for a primitive exceeds the plan quota for the current period the call returns HTTP 429 rate_limited. Calling a primitive the kit disables returns forbidden (primitive_disabled_by_kit). Both are enforced by Naïve, not by your code.
Which primitives are metered against a plan quota?+
Per the Customer Billing docs, the metered primitives are seo, aeo, and email — each records one usage event per successful call and returns 429 rate_limited when the tenant exceeds its plan quota. quotas maps a primitive slug to its max calls per period (month or day). Tenants with no subscription are not quota-limited, so seed a free-plan subscription on signup if you want every customer metered.
How do upgrades and downgrades work?+
Drive naive.forUser(tenantId).billing.setSubscription({ planKey, status, assignKit }) from your Stripe webhook. assignKit defaults to true, so the same call that records the subscription also assigns the plan's Account Kit — one call applies the new tier's permissions and quotas. Because enforcement is read at call time, the change is live on the tenant's next request: an upgrade enables the aeo/email primitives and raises quotas immediately; a downgrade re-gates them.
Where does OpenRouter fit in?+
Naïve's /llm primitive is a full wrapper over OpenRouter and forwards the request body verbatim. This build uses naive.forUser(id).llm.chat({ model: 'anthropic/claude-sonnet-4.6', response_format: { type: 'json_schema', ... } }) to turn the raw SEO and AEO data into a structured content brief. llm is billed in Naïve credits from OpenRouter's returned usage.cost (read res.credits_used); it is separate from the seo/aeo/email quota metering, so you can price LLM cost through independently.
What does this cost to run?+
Metering and quota enforcement themselves are free — the Customer Billing primitive adds no credit cost. You pay the normal per-call credit cost of the underlying primitives per live docs: SEO Keywords Data live calls are 10 credits, SEO Labs live calls are 20 credits, AEO LLM Responses are 30 credits, and the brief-composing llm.chat call bills at OpenRouter's reported cost. Because those calls are metered per tenant, you set each plan's quota so the price the customer pays comfortably covers the credits their tier can spend.
How do I get started?+
Install the SDK (npm install @usenaive-sdk/server), create one Account Kit per tier, upsert a plan per tier that maps to its kit plus quotas, subscribe each tenant from your Stripe webhook, and run the detect → audit → brief loop below with naive.forUser(tenantId). Full primitive docs: usenaive.ai/docs/getting-started/customer-billing, usenaive.ai/docs/getting-started/account-kits, usenaive.ai/docs/getting-started/seo, and usenaive.ai/docs/getting-started/aeo.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading