view .md
Guides · Grow

Billing

HubSpot Marketplace is a discovery and install channel, not a billing system. You collect payment yourself, through Stripe. This guide walks both ways to do that: the declarative path, where the catalog in hsx.config.ts plus one billing line per capability is the entire integration, and the self-managed path, where you own the checkout route, the webhook, and the entitlement helper end to end.

Outcome
A working billing path for Email Guard: plans, a verifications meter, and a free defaultPlan declared in hsx.config.ts; plan enforcement and usage metering bound to the capabilities that earn money; and an upgrade link that opens Stripe Checkout with the trial and redirects already applied.
Prerequisites
  • The Email Guard app from the getting-started guide deployed, or any HS-X app of your own.
  • A Stripe account in test mode. Paddle and Lemon Squeezy work the same way for the self-managed path; the patterns transfer one for one.
  • For the declarative path: a linked deploy and a connected Stripe account. For the self-managed path: a KV namespace bound to your Worker.

TL;DR — The HubSpot Marketplace lists your app and runs the OAuth install; it never touches money. On HS-X you declare plans, meters, and a free defaultPlan in hsx.config.ts, add billing: { requires, meter } to the capabilities that earn money, and deploy: the platform provisions Stripe, resolves fresh installs to the free plan, blocks or warns at limits, meters usage idempotently, and serves checkout from ctx.billing.upgradeUrl(). Running Stripe yourself remains fully supported — checkout redirect, customer-to-portal webhook, one entitlement helper — and this guide covers both.

HubSpot sells discovery; you collect the money

HubSpot does not collect payment for third-party Marketplace apps. There is no marketplace billing API, no app-store revenue share, no subscription record HubSpot manages on your behalf. The pricing card on your listing is informational: it tells prospective buyers what you charge, and that is the full extent of HubSpot's involvement in your revenue.

What HubSpot provides is discovery (the listing), install (the OAuth grant), and identity (the portal id scoped onto every webhook and API call). What you provide is everything between "clicked install" and "paid you money": the checkout page, card processing, receipts, tax handling, dunning, and the entitlement enforcement that keeps free users out of paid features. The marketplace listing guide covers getting found and approved; this page covers getting paid after install.

Every revenue model for a HubSpot app is a variant of one of three shapes:

ShapeWho handles moneyBest forEngineering cost
Subscription through your own billing (Stripe, Paddle, Lemon Squeezy)You, through a hosted checkoutSelf-serve product apps with repeatable pricingModerate; the rest of this guide
One-time license or contractYou, usually invoicedAgency-sold apps, B2B custom builds, six-figure dealsLow engineering, high sales effort
Free app plus paid servicesYou, off-platformAgencies, consultancies, implementation shopsAlmost none; the app is a lead magnet

The dominant path is the first one. Stripe Checkout absorbs the PCI scope, supports trials and metered billing natively, and ships a customer portal you can deep-link for plan changes. Paddle and Lemon Squeezy are merchant-of-record alternatives that handle VAT and sales tax for you; only the API names change. The one-time license path is mostly a sales process: you sign, you invoice, you write the entitlement row by hand. The free-plus-services path involves no billing code at all.

Which billing path fits which app

The decision is mostly determined by what your app does and who buys it, not by what you would prefer to build. If your situation does not fit the matrix cleanly, default to a Stripe subscription with a 14-day trial; it is the most reversible choice.

App shapePathWhy
Pure-HubSpot point app (one workflow action, one card)Stripe subscription, flat monthlySimple price, simple gating. Trials convert well when the value is visible in one panel.
Cross-platform product where HubSpot is one integration of manyYour existing billing, mirrored to a HubSpot SKUPricing already exists on your site. A HubSpot-only tier confuses buyers.
Usage-based (per record synced, per AI call)Stripe metered billingStripe and Paddle handle metered subscriptions cleanly. Avoid building your own metering.
Free tool, paid implementationFree in marketplace, services billed off-platformNo transaction inside the install flow at all.
Enterprise with custom contractsDirect, invoicedSix-figure deals do not sign through a card form. Provision entitlement by hand.

Two notes that do not fit in the cells. You can run several of these in parallel: a self-serve Stripe tier for small customers, invoiced contracts for enterprise. The entitlement helper does not care which one filled the row. And switching shapes after you have live customers is painful in both directions, so pick the shape that fits your sales motion eighteen months from now.

Three mistakes to skip:

  • Assuming Marketplace billing exists because Shopify's does. It does not; HubSpot will not collect a cent on your behalf.
  • Picking metered billing because it sounds modern. It is the right answer when usage varies tenfold across customers; otherwise flat pricing is easier to forecast on both sides.
  • Building your own checkout form to save the processor fee. PCI compliance, fraud handling, and 3DS will cost more than the fee. Use Stripe Checkout or Elements.

Plans and prices live in hsx.config.ts

Before any wiring, write the catalog down: which plans exist, what each one unlocks, what each one costs. In HS-X that declaration is the billing field of defineApp, and hs-x check validates it before anything deploys. On a linked deploy this declaration is not documentation — it is the setup step. The deploy persists it to the platform, provisioning creates the Stripe objects from it, and the enforcement described in the next section reads from it.

Email Guard's unit of cost is obvious once you look for it: a verification. Every validate-email run and every email-changed re-check spends one call against the verification provider, so that is what the meter counts and what the paid tier prices. A meter needs an id and an aggregation (sum here; count and last are the other modes) — the Stripe-facing event name is derived from your project and meter ids, and you only set eventName yourself when adopting a meter that already exists in your Stripe account.

// hsx.config.ts
import { defineApp } from "@hs-x/sdk";
 
export default defineApp({
  name: "Email Guard",
  distribution: "marketplace",
  auth: "oauth",
  platformVersion: "2026.03",
  scopes: ["crm.objects.contacts.read", "crm.objects.contacts.write"],
  billing: {
    provider: "stripe",
    mode: "self-managed",
    defaultPlan: "free",
    trialDays: 14,
    successUrl: "https://emailguard.dev/billing/done",
    cancelUrl: "https://emailguard.dev/billing/canceled",
    meters: [{ id: "verifications", aggregation: "sum" }],
    plans: [
      {
        id: "free",
        features: ["validate.workflow", "card.health"],
        limits: { verifications: 1_000 },
      },
      {
        id: "team",
        features: ["validate.workflow", "card.health", "suppression.sync", "validate.auto"],
        limits: { verifications: 50_000 },
        prices: [
          { id: "team-monthly", kind: "flat", interval: "month", amount: 4900, currency: "usd" },
          {
            id: "verifications-overage",
            kind: "metered",
            meterId: "verifications",
            interval: "month",
            currency: "usd",
            includedUnits: 50_000,
            pricing: {
              model: "graduated",
              tiers: [
                { upTo: 50_000, unitAmount: 0 },
                { upTo: "inf", unitAmountDecimal: "0.3" },
              ],
            },
          },
        ],
      },
    ],
  },
});

The free plan covers the workflow action and the email-health card with 1,000 verifications a month; team raises the cap to 50,000, adds the suppression-list sync and the email-changed auto re-verification, and prices overage at 0.3 cents per verification past the included units.

defaultPlan is the line that makes freemium work. A plan with features and limits but no prices is a free plan, and naming it as the default means every install that has never checked out resolves to it: real features, real limits, no Stripe customer, no code. Without a default plan, an install with no subscription has no entitlements at all, and anything gated on a feature is denied until checkout — the validator warns about exactly this combination.

Prices come in three kinds: flat (a monthly or yearly amount), seat (per-seat with the same intervals), and metered (priced against a declared meter, with per_unit, graduated, volume, or package pricing). hs-x check enforces the footguns it can see statically: a metered price that references no meter is an error, plan limits keyed by an undeclared meter are an error, a defaultPlan that names no declared plan is an error, and volume tiers produce a warning because they reprice all usage at the reached tier, which surprises almost everyone the first time.

The catalog is plain TypeScript, so your own code can import it and the pricing page, the checkout call, and the entitlement gates never drift apart. trialDays, successUrl, and cancelUrl are not decoration either: platform checkout applies the trial and falls back to these URLs so callers don't repeat them.

Gate on feature names, not plan names: plans are a marketing artifact and they will change. You will add a starter tier, rename team to growth, and grandfather a legacy price. Code keyed on plan names breaks every time; code keyed on features (suppression.sync, validate.auto) survives. Keep feature names short, namespaced, and tied to a user-visible capability rather than an implementation detail: Email Guard's four map one-to-one onto the action, the card, the sync, and the trigger. Entitlement features answer "did they pay for this"; for operational toggles and rollouts, use feature flags instead.

One line per capability enforces the plan

The biggest billing bug in HubSpot apps is the leak: a paid feature reachable through a code path that forgot to check. The declarative answer is to move the check out of handler code entirely. Every capability — workflow action, card backend, trigger, sync — accepts a billing binding, and the runtime enforces it before your handler runs:

worker.action("validate-email", {
  label: "Validate email address",
  objectType: "contact",
  billing: { requires: "validate.workflow", meter: "verifications" },
  async handler({ enrolledObject }) {
    // Plan already enforced; usage already metered on success.
    // ...the format check, the verification call, and the write-back
  },
});

A binding has three parts, all optional:

  • requires — a feature (or array of features) the resolved plan must grant. Enforced before the handler; also fails closed when the subscription status is anything other than active or trialing, so lapsed and canceled portals stop without any code.
  • meter — a meter id (or { id, quantity }) recorded automatically after a billable success. A workflow action that returns ok meters; one that returns fail-stop, retry-later, or block does not. The idempotency key derives from the ids HubSpot already sends — the webhook delivery id for triggers, the callbackId for actions — so a redelivered webhook or a retried execution can never double-bill.
  • onLimit — what happens when period-to-date usage reaches the plan's limit for that meter: block (the default) denies with an upgrade message, warn lets it run and logs, allow ignores limits for that capability. Limits reset on the UTC calendar month; Stripe remains the source of truth for invoicing.

Denials speak each surface's native contract, so you never translate billing state into HubSpot semantics yourself. A gated workflow action completes with the BLOCK execution state and your message in workflow history — not an error, not a retry storm. A gated card backend or sync run returns 402 with error: "billing_gated" plus the reason, which the card renders as its upgrade state. A gated webhook trigger is acknowledged with 200 and dropped, because a 4xx would make HubSpot retry and eventually disable the subscription. Set deniedMessage on the binding when the default message isn't what you want users to read.

Two failure directions are deliberate and worth knowing. The gate fails closed on subscription state: a past_due or canceled portal is denied even if your handler would have worked. It fails open on missing machinery: an unlinked deploy with no platform billing, or a control-plane blip during the entitlement read, lets the dispatch through and logs — briefly under-enforcing a plan gate beats taking every paying customer down with the control plane. Entitlement reads are cached in the Worker for sixty seconds, so a cancellation takes effect within a minute without paying a control-plane round trip per invocation.

hs-x check cross-checks bindings against the catalog: a meter that names an undeclared meter is an error, a requires feature that no plan grants is a warning, and requires without a defaultPlan warns that fresh installs will be denied until checkout.

The manual API remains underneath for shapes the binding can't express — metering by result size, gating on a combination of features, recording credits: ctx.billing.current() resolves the plan/status/features/limits/usage snapshot, recordUsage(meterId, quantity, { idempotencyKey }) writes one ledger row, recordCredit is the same with a required reason. Bindings and manual calls share the same ledger and the same idempotency rules.

What a linked deploy does end to end

With the catalog declared and the bindings in place, hs-x deploy on a linked project with a connected Stripe account is the entire billing integration. The runtime path — install to metered usage — looks like this:

In order:

  1. Deploy persists the catalog. The billing declaration rides the deploy plan to the control plane and fails the deploy loudly if it's invalid — no silently shipping a worker with no catalog behind it. Redeclaring preserves the Stripe ids of already-provisioned objects.
  2. Provisioning creates the Stripe catalog. Products, prices, and billing meters are created or updated from the declaration (event names derived as hsx.<project>.<meter>), and the generated ids are written back. The dashboard's billing tab shows sync state per object.
  3. Installs resolve to the default plan. An install with no subscription gets the defaultPlan's features and limits from the entitlement read — freemium works before any money moves.
  4. Upgrade is a URL. await ctx.billing.upgradeUrl() returns a Stripe Checkout link: the plan defaults to your single purchasable plan, successUrl/cancelUrl come from the declaration, trialDays is applied, and the full install identity rides the subscription metadata. Render it in the card's upgrade state or your settings page; pass { planId } once you have more than one paid tier.
  5. Webhooks maintain entitlements. Stripe subscription events are signature-verified and written as per-install entitlement snapshots keyed by the full install identity, so the next gated dispatch sees the new plan within the cache window.
  6. Usage flows to Stripe in near-realtime. Metered usage lands in a ledger with idempotency keys, and the control plane pushes each accepted write to Stripe Billing Meters behind the response — seconds, not a batch window, so threshold invoices fire on time and Stripe-side usage stays current. An hourly sweeper retries anything that failed (a Stripe outage never blocks the usage write) and picks up rows recorded in the brief window before a new subscription's customer mapping landed. Delivery state — pending, delivered, skipped, failed — is visible per record in the dashboard.

For Email Guard the quota UI comes free too: the email-health card renders "38,114 of 50,000 verifications used" from current()usage("verifications").periodToDate against limit("verifications").included — without you persisting a counter anywhere.

The mode in your declaration decides the economics: self-managed keeps HS-X fee-free and out of your revenue even when linked, while hsx-platform runs through Stripe Connect with the HS-X application fee applied. On an unlinked deploy ctx.billing is undefined, bindings fail open, and the self-managed pattern in the next sections is the whole story.

Post-install checkout, in your code

Everything from here to the lifecycle section is the self-managed path: you run Stripe yourself, HS-X stays out of the money entirely, and the platform machinery above is replaced by code you own. It is the right path for unlinked deploys, for apps whose billing already exists on another platform, and for anyone who wants Stripe under their own keys. The code in this section is yours, not SDK surface. The flow has five hops:

  1. A user installs your app from the listing; HubSpot runs the OAuth grant and the install lands.
  2. Your first-run surface (for Email Guard, the email-health card's empty state, or the page your listing points users at) shows the "start your trial" call to action with the portal id in hand.
  3. Your billing route creates a Stripe Checkout session with the portal id as client_reference_id and redirects the user to Stripe's hosted form.
  4. Stripe processes the card and fires checkout.session.completed at your webhook endpoint.
  5. Your handler writes the entitlement row and the customer-to-portal mapping.

Set the HubSpot portal id as the Checkout session's client_reference_id; the webhook echoes it back, and that echo becomes your customer-to-portal mapping. Without it you cannot tie the resulting subscription to a portal.

// Your billing route. The portal id is the breadcrumb.
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: "price_...", quantity: 1 }],
  client_reference_id: portalId,
  subscription_data: { trial_period_days: 14 },
  success_url: `https://yourapp.com/billing/done?portal=${portalId}`,
  cancel_url: "https://yourapp.com/billing/canceled",
});
return Response.redirect(session.url, 303);

The webhook endpoint is a plain Cloudflare Worker (or any HTTPS endpoint) that verifies Stripe's signature, dedupes retries, and writes two KV rows. Bind the same KV namespace to your HS-X Worker so the entitlement helper in the next section reads what this writes.

// billing-webhook.ts — a plain Worker you own end to end.
import Stripe from "stripe";
 
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const stripe = new Stripe(env.STRIPE_SECRET_KEY);
    const event = await stripe.webhooks.constructEventAsync(
      await request.text(),
      request.headers.get("stripe-signature") ?? "",
      env.STRIPE_WEBHOOK_SECRET,
    );
 
    // Stripe retries until you return 200; bail on replays.
    if (await env.ENTITLEMENTS.get(`processed:${event.id}`)) return new Response("ok");
 
    if (event.type === "checkout.session.completed") {
      const session = event.data.object;
      const portalId = session.client_reference_id;
      await env.ENTITLEMENTS.put(`entitlement:${portalId}`, JSON.stringify({
        planId: "team",
        status: "trialing",
        stripeCustomerId: session.customer,
        stripeSubscriptionId: session.subscription,
        updatedAt: Date.now(),
      }));
      await env.ENTITLEMENTS.put(`stripe:${session.customer}`, JSON.stringify({ portalId }));
    }
 
    if (event.type === "customer.subscription.updated" ||
        event.type === "customer.subscription.deleted") {
      const sub = event.data.object;
      const mapping = await env.ENTITLEMENTS.get<{ portalId: string }>(`stripe:${sub.customer}`, "json");
      if (mapping) {
        await env.ENTITLEMENTS.put(`entitlement:${mapping.portalId}`, JSON.stringify({
          planId: planForPrice(sub.items.data[0]?.price.id),
          status: event.type.endsWith("deleted") ? "canceled" : sub.status,
          stripeCustomerId: sub.customer,
          stripeSubscriptionId: sub.id,
          updatedAt: Date.now(),
        }));
      }
    }
 
    await env.ENTITLEMENTS.put(`processed:${event.id}`, "1", { expirationTtl: 86_400 });
    return new Response("ok");
  },
};

The two writes in the checkout branch are the durable heart of the system. The entitlement: row is read by every feature gate; the stripe: row lets every later subscription event resolve the customer back to a portal. Keep your Stripe keys and the webhook secret out of git; the secrets guide covers where they live.

Three wiring issues that surface in week one:

  • The success page often lands before the webhook does. Poll your entitlement endpoint for up to ten seconds, or render a "setting up your account" state, before dropping the user into the app.
  • Test mode and live mode use separate webhook endpoint configurations in Stripe. Configure both up front so flipping the live switch does not silently break the integration.
  • Webhooks get lost, rarely but really. Run a daily reconciliation job (a Cloudflare cron trigger on this same Worker) that walks the entitlement rows, fetches each live subscription from Stripe, and writes back any drift. Most days it touches zero rows.

One helper gates every paid surface

Self-managed gating is the leak problem from the binding section without the runtime to catch it: the check lives next to the feature, gets copy-pasted, and one branch drifts. The fix is mechanical discipline. One helper, one file, called from every surface, with no inline alternative tolerated in review.

An entitlement check fails closed: active and trialing pass, and every other status (or a missing row) is denied. The status comes straight from the Stripe subscription object; do not invent your own state machine on top of it.

// src/lib/entitlement.ts — yours; the only file that reads entitlement rows.
type EntitlementRow = {
  planId: string;
  status: "active" | "trialing" | "past_due" | "canceled" | "paused";
};
 
const PLAN_FEATURES: Record<string, readonly string[]> = {
  free: ["validate.workflow", "card.health"],
  team: ["validate.workflow", "card.health", "suppression.sync", "validate.auto"],
};
 
export async function hasFeature(
  kv: KVNamespace,
  portalId: string,
  feature: string,
): Promise<boolean> {
  const row = await kv.get<EntitlementRow>(`entitlement:${portalId}`, "json");
  if (!row) return false;
  if (row.status !== "active" && row.status !== "trialing") return false;
  return PLAN_FEATURES[row.planId]?.includes(feature) ?? false;
}

Call it at the top of every paid handler. In a workflow action, gating is a plain return:

import { failStop } from "@hs-x/sdk";
import { hasFeature } from "../lib/entitlement";
 
worker.action("validate-email", {
  label: "Validate email address",
  objectType: "contact",
  async handler({ env, install }) {
    const kv = env.ENTITLEMENTS as KVNamespace;
    if (!(await hasFeature(kv, install.portalId, "validate.workflow"))) {
      return failStop("Email Guard's plan for this portal is inactive. Renew or upgrade in app settings.");
    }
    // ...the format check, the verification call, and the write-back
  },
});

The free plan includes validate.workflow, so this gate mostly catches lapsed and canceled portals; that is exactly the job. In the email-health card backend the same check decides what the handler returns, and the card renders the result: the verdict panel when entitled, an upgrade prompt with a deep link to your billing page when not. There is no special error type for this; gating is ordinary control flow plus an honest message.

Two leak paths deserve special attention. Scheduled work keeps running after a downgrade unless you re-check on every run, so put the entitlement check inside your sync and cron handlers, not just at setup time; for Email Guard that means the suppression-list sync checks suppression.sync at the top of every run. And the KV read can show up in latency profiles on hot paths; a per-request cache is fine, but never cache entitlements across requests, because that is where stale-plan bugs live.

Trials, upgrades, downgrades, cancellations

Four flows decide whether churn is high or recoverable. Stripe handles the money in each one; you handle the customer experience around the money.

Trial expiration. Conversion lives in three communication windows: a day-3 check-in, a day-11 "trial ends in three days" warning, and a day-14 "here is what changes" notice. Schedule them from the trial end date on the subscription, send them through your email provider, and make the sender replyable. When the trial lapses unconverted, the subscription status changes and your webhook writes the row; the gate does the rest.

Upgrade. Easy, because the customer is asking to pay you more. Deep-link to the Stripe customer portal and let the webhook update the row. The one trap is the post-upgrade race: payment completes, the user clicks the feature they just bought, and the webhook has not landed yet. Poll the entitlement on your upgrade-success page for a few seconds, and have the upgrade prompt show a "just upgraded? refresh in a moment" state when it sees a gate denial within a minute of a known upgrade click.

Downgrade. The hard one, because paid resources keep running unless you stop them. When a subscription event drops to a lower tier, walk the paid resources the old plan allowed and pause what the new plan does not cover; for Email Guard a drop from team pauses the suppression-list sync and the email-changed re-verification while the action and the card keep working. Email a one-paragraph "here is what got paused, click to restore" notice.

Cancellation. One click in the Stripe customer portal, no retention dark patterns. When the webhook fires, write the row to canceled, leave the customer's data in place for 30 days so a returning customer gets back what they had, and send a short re-subscribe link. Many cancellations are temporary.

Dunning sits across all four. When a card fails, Stripe retries on its Smart Retries schedule for about two weeks. During that window keep the status as past_due rather than canceled: the gate denies access either way, but past_due lets your in-app banner say "your payment failed, update your card" instead of "your subscription was cancelled", and the dunning email can carry a one-click deep link to the customer portal with the concrete retry date from the event payload.

One abuse case worth handling early: trial chaining across portals. Dedupe trials by admin email and Stripe customer, not by portal id, and skip straight to paid Checkout when the email already burned a trial. Watch the webhook failure and entitlement drift rates once you are live; the monitoring guide covers the alerts.