Billing: turning your HS-X app into revenue.
HubSpot Marketplace is a discovery and install channel, not a billing system. You collect payment yourself, usually through Stripe. This guide walks the actual mechanics: the post-install Checkout redirect, the webhook that maps a Stripe customer to a HubSpot portal, one entitlement helper every paid surface calls, and the trial/upgrade/downgrade/cancel flows that decide your churn.
Before you begin
This is the section that breaks most developers' assumptions about HubSpot, so read it carefully. HubSpot does not collect payment for third-party marketplace apps. There is no marketplace billing API, no "buy" button HubSpot wires for you, no native subscription record HubSpot manages on your behalf. The pricing card on your listing page is presentational only; it tells prospective buyers what you charge, and that is the entire scope of HubSpot's involvement.
What HubSpot does provide is discovery (your listing in the App Marketplace), install (the OAuth grant flow), and identity (the portal id every webhook and API call is scoped to). What you provide is everything between "user clicked install" and "user pays you money." That includes the checkout page, the card processing, the dunning emails, the tax handling, the receipts, and the entitlement enforcement that keeps free users out of paid features. If you came in expecting an app-store-style 70/30 revenue share with the platform handling the cards, recalibrate now.
The three monetization shapes
Every HS-X app revenue model is a variant of one of three patterns. Pick on purpose and the rest of this guide collapses to the relevant section.
| Shape | Who handles money | Best for | Engineering cost |
|---|---|---|---|
| Subscription via your own billing (Stripe, Paddle, Lemon Squeezy) | You, through a hosted checkout | Self-serve product apps with repeatable pricing | Moderate — the rest of this guide |
| One-time license / contract | You, often invoiced manually | Agency-sold apps, B2B custom builds, six-figure deals | Low engineering, high sales cost |
| Free app + paid services | You, off-platform | Agencies, consultancies, implementation specialists | Almost none — the app is a lead magnet |
The dominant path is the first one. Stripe is the default because Stripe Checkout absorbs the PCI scope, supports trials and metered billing natively, and has a customer portal you can deep-link users to for plan changes. Paddle and Lemon Squeezy are popular alternatives if you want a merchant-of-record that handles VAT and sales tax for you; the wiring is identical, only the API names change. If you use HS-X platform billing instead of your own billing integration, HS-X provisions this path through Stripe Connect and applies the HS-X platform application fee to app revenue. If you run locally or wire billing yourself, HS-X is not in the money path.
The one-time license path is mostly a sales process. You sign a contract, you invoice, and you flip an entitled: true row in your KV store by hand (or via an admin command). There is no recurring billing surface to build. Skip to step 5 if this is you — the entitlement helper is the only piece that matters.
The free-app-plus-services path is, technically, no billing at all. The app stays free in the marketplace, generates installs, and a percentage of those installs convert to consulting engagements that you bill outside the app entirely. Works well when your business is human-leveraged services, works poorly when it is product-leveraged software.
The mental model
Your app has three runtime surfaces that need to know whether the caller is allowed to do the thing: the Worker (running sync jobs, workflow actions, agent tools), the UI extension (rendering panels and buttons in HubSpot), and any scheduled job (cron tasks, retry queues). All three read from a single per-portal entitlement row. Stripe writes that row through a webhook. Every paid code path reads it through one helper. The helper is the entire entitlement layer; if you only remember one thing from this page, remember that.
This guide assumes you have already shipped your listing or are working toward one. The mechanics of getting approved (questionnaire, sensitive scopes, screenshots, review timeline) are covered in the marketplace listing guide; this page is about getting paid after install.
Pick a monetization shape
The decision is mostly determined by what your app does and who buys it, not by what you would prefer to build. The matrix below covers the common cases. If your situation does not fit cleanly, default to a Stripe subscription with a 14-day trial — it is the most reversible choice and the one with the deepest documentation when you hit edge cases.
The decision matrix
| App shape | Path | Why |
|---|---|---|
| Pure-HubSpot point app (one workflow action, one card) | Stripe subscription, flat monthly | Simple price, simple gating. Trial converts well when the value is visible inside one panel. |
| Cross-platform product where HubSpot is one integration of many | Your existing billing, mirrored to a HubSpot SKU | Pricing already exists on your site. Adding a HubSpot-only tier confuses buyers. |
| Usage-based (per record synced, per AI token, per API call) | Stripe metered billing | Only Stripe (and Paddle) handle metered subscriptions cleanly. Avoid building your own metering. |
| Free tool, paid implementation (agency-style) | Free in marketplace, services billed off-platform | App is the lead magnet. No transaction inside the install flow. |
| Enterprise with custom contracts | Direct, invoiced | Six-figure deals do not sign through a card form. Provision entitlement by hand. |
| White-label or partner-distributed | Whatever the partner agreement says | Often the partner already owns the billing relationship; you flip entitlement on receipt of a partner webhook. |
Two notes that do not fit in the cells. First, you can run more than one of these in parallel — a Stripe self-serve tier for small customers, invoiced contracts for enterprise, and a free-services hook for agency partners. The entitlement helper does not care which one filled the row, as long as something did. Second, switching shapes after you have live customers is painful. Migrating from Stripe subscriptions to invoicing is doable but tedious; the reverse is worse. Pick the shape that fits your sales motion eighteen months from now, not the one that feels easiest tonight.
Why HubSpot has no native billing (and why that is fine)
Asking why HubSpot does not provide marketplace billing the way Shopify or Atlassian do is a fair question, and the answer affects how you design. HubSpot's marketplace is positioned as a directory rather than a transactional storefront: most installs are made by an admin who already has a procurement relationship with the app vendor, and HubSpot has chosen not to insert itself between vendor and buyer. The practical upshot for self-managed billing is that you keep the revenue minus your processor fee, own the customer record in full, and can run any pricing model you want — annual contracts, custom enterprise tiers, partner discounts. The cost is that you build the checkout, receipts, dunning, tax handling, and cancellation flow yourself. HS-X platform billing trades that build for a managed Stripe Connect rail with an HS-X application fee.
Common path-choice mistakes
- Assuming HubSpot Marketplace billing exists because Shopify's does. It does not. The price card on your listing is display-only; HubSpot will not collect a cent on your behalf.
- Picking metered billing because it sounds modern. Metered billing is the right answer when usage genuinely varies by 10x across customers and you cannot predict consumption. Otherwise a flat seat or flat monthly is easier to forecast on both sides.
- Building your own checkout form to "save the Stripe fee." You will spend more on PCI compliance, fraud handling, and 3DS than you will save in fees. Use Stripe Checkout or Stripe Elements.
Define your plans and entitlement schema
Before any code, write down the plans and the features each one unlocks. This is the document the entitlement helper compiles against, the document Stripe's product catalog mirrors, and the document your pricing page renders. Keep it in hs-x.toml so it stays version-controlled and the Worker can read it without a network call.
# hs-x.toml
[billing]
provider = "stripe"
trial_days = 14
upgrade_url = "https://yourapp.com/billing/portal"
[billing.plan.free]
stripe_price = ""
features = ["sync.basic", "card.read"]
limits = { records_per_month = 1_000, syncs = 1 }
[billing.plan.team]
stripe_price = "price_1QABCDEF..."
features = ["sync.basic", "sync.bidirectional", "card.read", "card.edit", "agent.tools"]
limits = { records_per_month = 50_000, syncs = 10 }
[billing.plan.scale]
stripe_price = "price_1QXYZ..."
features = ["*"]
limits = { records_per_month = -1, syncs = -1 }Why feature names beat plan names in code
Every check in your Worker should ask "does this portal have feature X" rather than "is this portal on plan Y." The reason is that plans are a marketing artifact and they will change. You will add a starter tier between free and team. You will rename team to growth when sales asks you to. You will introduce a grandfathered team_legacy that maps to the same features but a different price. Code keyed on plan names breaks every time; code keyed on feature flags survives.
A feature is a string you make up. Keep them short, namespaced, and tied to a user-visible capability rather than an implementation detail. sync.bidirectional is good. usesGraphqlEndpoint is bad. The feature set should be small enough to fit on one screen and stable enough that a six-month-old commit still references the same names.
The entitlement row shape
One row per portal in your KV namespace, keyed entitlement:${portalId}. Same shape regardless of which Stripe event wrote it, which keeps the entitlement helper trivial.
type Entitlement = {
plan: 'free' | 'team' | 'scale';
status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'paused';
stripeCustomerId?: string;
subscriptionId?: string;
trialEndsAt?: number; // unix ms
updatedAt: number;
adminEmail: string;
};The status field is what the entitlement helper actually gates on. active and trialing pass; everything else fails closed. The plan field is what the helper looks up to decide which features the row unlocks. Both come straight from the Stripe subscription object — do not invent your own state machine on top.
What just got declared
Three things now exist: a plan-to-feature table the Worker can read at runtime, an entitlement row shape every webhook handler will write, and a single source of truth for what each tier costs. None of it is wired yet; that is the next three steps.
Wire post-install checkout
The flow you are building: a user clicks Install on your marketplace listing, HubSpot's OAuth dialog appears, they grant scopes, HubSpot redirects back to your callback, your Worker exchanges the code for a token and stores the install, then your Worker redirects the user to Stripe Checkout with the portal id stashed as a breadcrumb. Stripe processes the card, fires a webhook back to your Worker, and you persist the resulting subscription against the portal. From the user's perspective, install and pay are two steps, with the second one happening in Stripe's hosted UI.
Five hops from click-install to entitled-portal.
HubSpot owns hops 1–3 (OAuth and install webhook). Stripe owns hops 4–5 (Checkout and the subscription webhook). Your Worker is the connective tissue.
The install handler
HubSpot fires app.installed when OAuth completes. HS-X verifies the payload and hands you a typed event with the portal id and the installing user's email. Use them to start a Stripe Checkout session and redirect.
import { defineWorker, env } from '@hs-x/sdk';
export default defineWorker(({ worker }) => {
worker.trigger('hubspot.app.installed', async (ctx) => {
const { portalId, userEmail } = ctx.event;
const session = await ctx.http.post('https://api.stripe.com/v1/checkout/sessions', {
headers: { Authorization: `Bearer ${env('STRIPE_SECRET')}` },
form: {
mode: 'subscription',
'line_items[0][price]': env('STRIPE_PRICE_TEAM'),
'line_items[0][quantity]': 1,
customer_email: userEmail,
client_reference_id: portalId,
success_url: `${env('APP_URL')}/billing/done?portal=${portalId}`,
cancel_url: `${env('APP_URL')}/billing/cancel?portal=${portalId}`,
'subscription_data[trial_period_days]': 14,
},
});
await ctx.kv.put(`entitlement:${portalId}`, {
plan: 'free',
status: 'trialing',
adminEmail: userEmail,
updatedAt: Date.now(),
});
return { redirect: session.body.url };
});
});Two details worth pausing on. The client_reference_id is the breadcrumb Stripe echoes back in the webhook payload; without it you cannot map the resulting subscription to a portal. The pre-write of a free + trialing row before Checkout completes is deliberate — it means the user is never in an undefined state, even if they bounce out of Checkout and come back later. The webhook upgrades the row when (and if) they pay.
The webhook handler
Stripe fires three events you care about: checkout.session.completed when the customer pays, customer.subscription.updated for every plan change after that, and customer.subscription.deleted on cancellation. Declare one trigger; switch on event.type inside.
worker.trigger('stripe.webhook', async (ctx) => {
const { event, kv } = ctx;
if (event.type === 'checkout.session.completed') {
const portalId = event.data.object.client_reference_id;
await kv.put(`entitlement:${portalId}`, {
plan: 'team',
status: 'trialing',
stripeCustomerId: event.data.object.customer,
subscriptionId: event.data.object.subscription,
adminEmail: event.data.object.customer_details.email,
trialEndsAt: Date.now() + 14 * 86_400_000,
updatedAt: Date.now(),
});
return;
}
if (event.type === 'customer.subscription.updated' ||
event.type === 'customer.subscription.deleted') {
const customerId = event.data.object.customer;
const mapping = await kv.get(`stripe:${customerId}`);
if (!mapping) return;
const plan = priceToPlan(event.data.object.items.data[0].price.id);
await kv.put(`entitlement:${mapping.portalId}`, {
plan: event.type.endsWith('deleted') ? 'free' : plan,
status: event.type.endsWith('deleted') ? 'canceled' : event.data.object.status,
stripeCustomerId: customerId,
subscriptionId: event.data.object.id,
adminEmail: mapping.adminEmail,
updatedAt: Date.now(),
});
}
});Signature verification is automatic when the trigger id is stripe.webhook — HS-X pulls the endpoint secret from your secret store (see the secrets guide for rotation) and rejects invalid signatures before your handler runs. Idempotency is on you: Stripe retries until you 200, so keep a processed:${event.id} key in KV and bail early if the event has been seen.
Common checkout-wiring issues
- The
client_reference_idfield has a 200-character limit. HubSpot portal ids are short integers, so you are fine, but if you ever encode JSON in there you will hit it. - The success page lands before the webhook does, roughly 30% of the time. Have the success page poll your entitlement endpoint for up to 10 seconds before redirecting into the app, or render an explicit "Setting up your account..." state.
- Stripe Checkout in test mode uses a separate webhook endpoint configuration than live mode. Configure both at the same time so your test integration doesn't break when you flip the live switch.
Persist the portal-to-customer mapping
Stripe knows about customer ids and subscription ids. HubSpot knows about portal ids. Your Worker is the only system that knows the link, and persisting that link correctly is the single most important durable write in your billing system. Lose it and you cannot reconcile a webhook with a portal; corrupt it and a Stripe event for customer A writes to portal B.
Two keys, both written from the checkout handler
// inside checkout.session.completed
await ctx.kv.put(`entitlement:${portalId}`, { /* ...plan, status, etc */ });
await ctx.kv.put(`stripe:${customerId}`, { portalId, adminEmail });The entitlement:${portalId} key is read by every feature gate. The stripe:${customerId} key is read by every subsequent Stripe webhook so it can resolve the customer back to a portal. Both writes happen in the same handler, so if either one fails the customer state is recoverable from the other — that is the redundancy you want.
For higher-scale apps, push this into a control-plane DB call instead of KV. A relational store gives you a proper unique constraint on (portal_id, stripe_customer_id) and a transaction across both writes, neither of which KV offers. The HS-X control-plane exposes a billing.upsertMapping call that does both writes in a single round trip; use it once your install rate climbs past a few hundred a day and KV eventual-consistency starts to bite.
Why you cannot trust Stripe's customer metadata alone
Stripe lets you stash arbitrary metadata on a customer object, and it is tempting to put the portal id there and skip the mapping row entirely. Do not. Two reasons. First, metadata reads cost an API call per webhook, which adds 100–300ms to every event. Second, the Stripe customer object is mutable by anyone with API access to your account, and a bad merge from your support team can rewrite the portal id silently. A dedicated mapping row in your store is the source of truth; Stripe's metadata is a backup at best.
Reconciling drift
Webhooks get lost. Stripe is famously reliable but not infallible, and your Worker can crash mid-handler. Run a daily reconciliation cron that walks every entitlement row, fetches the live Stripe subscription, and writes any deltas back. Cheap to run, catches every drift the webhook missed.
worker.action('reconcileBilling', async (ctx) => {
const rows = await ctx.kv.list({ prefix: 'entitlement:' });
for (const { key, value } of rows) {
if (!value.subscriptionId) continue;
const sub = await ctx.http.get(
`https://api.stripe.com/v1/subscriptions/${value.subscriptionId}`,
{ headers: { Authorization: `Bearer ${env('STRIPE_SECRET')}` } },
);
const livePlan = priceToPlan(sub.body.items.data[0].price.id);
if (sub.body.status !== value.status || livePlan !== value.plan) {
await ctx.kv.put(key, {
...value,
plan: livePlan,
status: sub.body.status,
updatedAt: Date.now(),
});
}
}
});Schedule it with worker.trigger('cron.daily', ...) at a low-traffic hour. Most days it touches zero rows.
Common mapping issues
- A user installs your app twice on the same portal (uninstall, reinstall). The second install fires
app.installedagain and overwrites the entitlement row totrialing— but the Stripe subscription is still live. Check for an existingstripe:${customerId}mapping before writing a new trial row. - A customer changes their email in Stripe. Your
adminEmailfield goes stale, and dunning emails go to the wrong address. Re-syncadminEmailfromcustomer.updatedevents, not just the install handler. - A portal merges with another (HubSpot allows admin portal merges in some account tiers). One portal id disappears. The reconciliation cron is the only thing that will catch this; alert when an entitlement row references a portal id that returns 404 from HubSpot's portal API.
Centralize entitlement checks
The biggest billing bug in HubSpot apps is the leak — a paid feature accessible through a code path that forgot to check entitlement. It happens because the check lives next to the code, gets copy-pasted, and one branch drifts. The fix is mechanical: one helper, called from every surface, with no inline alternative tolerated in code review.
The helper, one file, one signature
// lib/entitlement.ts
import type { Ctx } from '@hs-x/sdk';
type Feature =
| 'sync.basic'
| 'sync.bidirectional'
| 'card.read'
| 'card.edit'
| 'agent.tools'
| 'agent.unlimited';
export async function requireFeature(ctx: Ctx, feature: Feature): Promise<void> {
const ent = await ctx.kv.get<{ plan: string; status: string }>(
`entitlement:${ctx.portalId}`,
);
const active = ent && (ent.status === 'active' || ent.status === 'trialing');
if (!active) {
throw new ctx.errors.PaymentRequired(`Subscription is ${ent?.status ?? 'missing'}.`);
}
const plan = ctx.config.billing.plan[ent.plan];
if (!plan) {
throw new ctx.errors.PaymentRequired(`Unknown plan: ${ent.plan}`);
}
if (!plan.features.includes(feature) && !plan.features.includes('*')) {
throw new ctx.errors.PlanUpgradeRequired({ feature, currentPlan: ent.plan });
}
}
export async function hasFeature(ctx: Ctx, feature: Feature): Promise<boolean> {
try {
await requireFeature(ctx, feature);
return true;
} catch {
return false;
}
}Two functions on purpose. requireFeature throws and is called before doing the thing. hasFeature returns a boolean and is called from UI code that needs to decide whether to render the upgrade prompt or the panel. Same source of truth, no drift.
Call it from every surface
import { requireFeature } from './lib/entitlement';
export default defineWorker(({ worker }) => {
worker.action('enrichContact', async (ctx, input) => {
await requireFeature(ctx, 'sync.bidirectional');
return enrich(input);
});
worker.tool('researchAccount', {
async run(ctx, input) {
await requireFeature(ctx, 'agent.tools');
return research(input);
},
});
worker.sync(airtableContacts, {
into: 'contacts',
schedule: '5m',
schema: { /* ... */ },
async beforeRun(ctx) {
await requireFeature(ctx, 'sync.basic');
},
});
});// inside the UI extension
const canEdit = await ctx.fn('hasFeature', { feature: 'card.edit' });
return canEdit ? <EditableInsights /> : <UpgradePrompt to="team" upgradeUrl={ctx.config.billing.upgrade_url} />;Note the beforeRun on the sync. Long-lived scheduled jobs are the most common leak path — they were authorized when the user was on the paid plan, kept running after downgrade, and quietly consumed your API quota for a customer who is no longer paying. Re-check on every run.
Why centralization pays for the indirection
One function, one bug surface. If you discover that trialing users should lose access on day 15 instead of day 14, you change one line. If you add a starter tier, you add it to the plan config and every check picks it up automatically. If a customer reports a leak, you grep the feature name and look at the one helper, not twelve scattered checks.
The cost is that junior contributors will sometimes try to "just check the plan directly" inline because the helper feels like ceremony. Catch it in review the first few times; after that, the pattern sticks. A linter rule that forbids ctx.kv.get('entitlement: outside lib/entitlement.ts formalizes it if your team is large enough to need the rule.
Common entitlement-helper issues
- The error you throw needs to be a known type (
PaymentRequiredorPlanUpgradeRequired) so HS-X's UI extension runtime renders the appropriate upgrade fallback. GenericErrorinstances render as a red "Something went wrong" banner, which trains users to file support tickets instead of upgrading. - Free users see paid features for a few hundred milliseconds after install because the entitlement row defaults to free, but the UI rendered before the row was written. Render an explicit loading state until the entitlement endpoint returns a non-null row.
- The KV read shows up in latency profiles. Add a per-request
Mapcache keyed byportalId(lifetime: one request, never across). Do not cache across requests — that is where the stale-entitlement bugs live.
Handle the lifecycle flows
Four flows decide whether your churn is high or recoverable: trial expiration, upgrade, downgrade, and cancellation. The billing provider handles the money for each; you handle the customer experience around the money. Skip any of them and you ship revenue leaks you will find by accident a quarter later.
Trial expiration
Trial-to-paid conversion lives in three communication windows: day 3 (the "did they actually try it" check-in), day 11 (the "your trial ends in three days" warning), and day 14 (the "trial ended, here is what changes" notice). Schedule them off the trialEndsAt timestamp from the entitlement row.
worker.trigger('cron.daily', async (ctx) => {
const trials = await ctx.kv.list({ prefix: 'entitlement:' });
const now = Date.now();
for (const { value } of trials) {
if (value.status !== 'trialing' || !value.trialEndsAt) continue;
const daysLeft = Math.floor((value.trialEndsAt - now) / 86_400_000);
if (daysLeft === 3) {
await ctx.email.send({
to: value.adminEmail,
template: 'trial-ending',
data: { daysLeft, upgradeUrl: ctx.config.billing.upgrade_url },
});
}
}
});The email binding comes from @hs-x/email. Templates live in the same package; override them per-app by dropping a same-named template in emails/.
Upgrade
Upgrades are easy because the customer is asking to give you more money. Render an in-app upgrade button (gated by hasFeature so it only appears below the target plan), deep-link to the Stripe customer portal, and let the webhook update the entitlement row. The new state lands within a few seconds of the customer completing the change.
The one trap is the post-upgrade race. The customer clicks upgrade, completes payment, returns to the app, and clicks the feature they upgraded for — but the webhook has not landed and the feature is still gated. Their reaction is "the upgrade did not work" and they email support. Two defenses: poll the entitlement endpoint on the upgrade-success page for ~5 seconds before redirecting back into the app, and have the UpgradePrompt component show a "Just upgraded? Refresh in a moment" inline state when it sees an entitlement-required error within 60 seconds of a known upgrade click.
Downgrade
Downgrades are the hard one because the customer is leaving features behind. Two failure modes: cached premium plan keeps users entitled after the row says otherwise, and paid resources (a high-frequency sync, a scheduled job, an expensive integration) keep running and quietly cost you money.
Run a downgradeReconcile handler when a customer.subscription.updated event drops to a lower tier. Walk every paid resource the old plan allowed, check whether the new plan still allows it, and pause or delete accordingly. Email the customer a one-paragraph "here is what got paused, click here to restore if this was accidental" notice.
worker.action('downgradeReconcile', async (ctx, { portalId, fromPlan, toPlan }) => {
const syncs = await ctx.kv.list({ prefix: `sync:${portalId}:` });
for (const { key, value } of syncs) {
if (value.frequency === '1m' && toPlan === 'free') {
await ctx.kv.put(key, { ...value, status: 'paused', pausedReason: 'downgrade' });
}
}
await ctx.email.send({
to: ctx.adminEmail,
template: 'plan-downgraded',
data: { fromPlan, toPlan, pausedSyncs: syncs.length },
});
});Dunning (failed payment)
When a card fails, Stripe retries on a configurable schedule (defaults to 4 attempts over 2 weeks via Smart Retries). Your job during the retry window is to keep the customer informed without locking them out — they are not malicious, the card just expired.
A working dunning email, short enough that people actually read it:
Subject: Your <App Name> payment didn't go through
Hi <name>,
Your card on file was declined when we tried to renew your <App Name>
subscription. We'll retry on <date>. To update your card now and avoid
any interruption, click below.
<Update payment method>
If you have any questions, just reply to this email.
- The <App Name> teamThree details that matter. The retry date is concrete (pulled from event.data.object.next_payment_attempt). The action button is one click — deep link to the Stripe customer portal, do not require a second login. The sender is a replyable address (billing@yourapp.com, not noreply@), so customers can respond instead of filing support tickets. During the dunning window, leave the entitlement status as past_due rather than canceled; the helper denies access either way, but past_due lets the in-app banner say "your payment failed, click to retry" instead of "your subscription was cancelled."
Cancellation
Cancellation should be one click in the Stripe customer portal. No retention dark pattern, no "are you sure?" loop, no required cancellation call. The retention work is what you do before the click — health-score emails, in-app prompts, a usage report — not friction in the cancel flow.
When the cancel webhook fires, write the row to { plan: 'free', status: 'canceled' }, leave their data in place for 30 days so a returning customer gets back what they had, then run a scheduled cleanup. Email a one-paragraph "your subscription ended" notice with a re-subscribe link. Many cancellations are temporary — the customer comes back in three months — and the soft re-entry path matters more than people expect.
Common failure modes across all four flows
- Webhook race on post-install. Covered above; success page polls for 10s, entitlement defaults to free not "no access," UI extension renders "just upgraded?" inline state for fresh 402s.
- Entitlement drift after marketplace plan changes. Daily reconciliation cron catches every event Stripe drops (this is rare but real at scale).
- PCI scope creep. Never accept raw card data through your Worker. Stripe Checkout (hosted) and Stripe Elements (tokenized) are the only paths that keep you in SAQ-A. Storing a
last4is fine; full PANs are the line you cannot cross. - Free-trial abuse via multi-portal install. A single person spins up multiple developer portals and installs your app under each to chain trials. The defense is to dedupe trials by
adminEmailand Stripe customer (Stripe blocks the same card across customers automatically), not by portal id. Detect onapp.installed— if the admin email already has an active or expired trial, skip directly to the paid Checkout.
What you built
Your app now has a billing path that fits its sales motion, a webhook that records subscription state, a single helper every paid feature calls, and four lifecycle flows that turn cancellations from leaks into recoverable events. The whole billing layer is roughly 200 lines of code, mostly because HS-X handles the webhook signature verification, the trigger registration, the secret loading, and the email sending underneath. Your job stopped being "build billing" and became "decide which feature gates which plan" — which is where it should have been from the start.
Where next
- How to · List on the marketplace — the listing copy, screenshots, sensitive-scope justification, and review process that gets your app approved. This guide was about getting paid; that one is about getting found.
- How to · Manage secrets — how HS-X stores and rotates the Stripe webhook secret, the Stripe API key, and any other billing-adjacent tokens without you ever committing them to git.
- How to · Monitor in production — dashboards and alerts that catch webhook failures, dunning bounces, and entitlement drift before your customers do.