Skip to content

Decision Log: HubSpot Subscriptions

Author: Michael Probber Created: 2026-10-20

Records the design decisions for selling Arda subscriptions through HubSpot and carrying what HubSpot bills into each tenant’s plan. They were settled in Round 1 (2026-10-19 – 2026-10-20), with DQ-003, DQ-004, DQ-005, DQ-006, DQ-011 and DQ-013 revised in Round 2 (2026-10-28) after review, and DQ-013 again in Round 3 (2026-10-30), and are reflected in Design: HubSpot Subscriptions.

#QuestionStatusDecisionRound
DQ-001What decides whether a tenant pays?DecidedHubSpot subscription statusR1
DQ-002How does a buyer pay?DecidedA quote on a checkout dealR1
DQ-003Where does the link between HubSpot and Arda live?DecidedTenant ID on each subscription (and on checkout deals); user-account ID on the contactR2
DQ-004Which contact property identifies the person?DecidedA new ardacloud_user_id holding UserAccount.eId, written only while blankR2
DQ-005Which subscription statuses count as paying?Decidedactive, unpaid, past_due, scheduledR2
DQ-006Does Arda read the tier?DecidedNo: any paying subscription is PaidR2
DQ-007How is the webhook authenticated, and what does it trust?DecidedKey header; subscription ID only; recomputeR1
DQ-008How do non-production partitions use HubSpot?DecidedA sandbox, through each partition’s own vaultR1
DQ-009Can an in-app upgrade change a paying tenant’s tier?DecidedNo: 409, and sales change itR1
DQ-010When does an upgrade reuse a deal?DecidedOpen in-app deal reused; closed neverR1
DQ-011Does the plan enum grow?DecidedNo: Personal / Paid; the tier stays in HubSpotR2
DQ-012How are concurrent upgrades kept to one checkout?DecidedConverge afterwards, in the Subscriptions pipeline onlyR1
DQ-013How does a subscription get its tenant?DecidedIn-app: copied from the checkout deal by the webhook. Otherwise: a reviewed backfill script, checked by a verify scriptR3

DQ-001: What decides whether a tenant pays?

Section titled “DQ-001: What decides whether a tenant pays?”

Context: HubSpot has two records that could answer this: the deal’s stage, which sales move, and the subscription’s status, which HubSpot’s billing sets from real payments.

OptionDescriptionTrade-offs
AThe deal’s stage: closed won is paidSimple; but a person must move the deal when a payment lands, and a failed card or cancellation isn’t reflected until someone notices
BThe subscription’s statusFollows real payments, failures and cancellations; needs each subscription to know its tenant

Decision: B. A tenant’s plan is derived from every subscription carrying its ID, and stages are sales’ own record.

Context: A HubSpot payment link attaches its payment to a contact, matched by the email typed at checkout. It joins a deal only if the link is associated with that deal and the buyer is too. HubSpot’s payment-links API can’t associate a link with an existing deal, and its subscriptions API can’t set associations at all: they come from the quote or payment link that started the subscription.

OptionDescriptionTrade-offs
AOne shared payment link per tierNo code; but payments land on whichever contact the typed email matches, and someone must attach each to a tenant’s deal by hand
BA payment link created per dealA plain checkout page; attaching it to the deal relies on undocumented association behaviour
CA quote per deal, payment enabledDocumented end to end: the quote is linked to the deal, and the payment and subscription follow it, whatever email is used; the buyer sees a quote page

Decision: C. Shared per-tier links remain suitable where no tenant is known, such as the marketing site.

Section titled “DQ-003: Where does the link between HubSpot and Arda live?”

Context: The webhook must find the tenant a subscription pays for, and the lead endpoint must find the person’s contact. Arda could store HubSpot IDs, or HubSpot could hold Arda’s IDs. The accounts service doesn’t let the API key set UserAccount.hubspotId. Subscriptions accept custom properties, and HubSpot search works on them. Existing customers’ subscriptions hang off deals in sales’ own pipelines.

OptionDescriptionTrade-offs
AStore HubSpot IDs in Arda (user account, tenant)Direct lookups; needs accounts changes, and a second copy of the mapping that can drift
BTenant ID on the subscription’s deals; a tenant’s subscriptions are those on its dealsWorks for in-app sales; existing customers’ deals sit in sales’ pipelines, which the flow would have to write to, and every lookup goes through deals
CTenant ID on the subscription itself, and on the checkout deal so a new subscription can take it; the user-account ID on the contactA tenant’s subscriptions are one search away; existing customers link without touching any deal; HubSpot is the one place the mapping lives

Decision: C. Plans are computed from subscriptions alone, through their own ardacloud_tenant_id. A checkout deal carries it only for the subscription its quote starts. It isn’t on the contact, because one person can pay for several tenants. No HubSpot ID is stored in Arda.

DQ-004: Which contact property identifies the person?

Section titled “DQ-004: Which contact property identifies the person?”

Context: Production contacts already carry arda_oidc_sub, a unique property holding the Cognito subject, maintained by the customer-data identity sync. The Cognito subject is an identity-provider value, not Arda’s identifier for a person: that is UserAccount.eId, the same kind of ID the subscription carries for its tenant.

OptionDescriptionTrade-offs
AA new ardacloud_user_id property holding UserAccount.eIdArda’s own identifier, matching ardacloud_tenant_id; existing contacts need it stamped once from their subject
Barda_oidc_sub, written by the BFF under the same blank-only ruleNo new property; ties HubSpot to the identity provider’s value rather than Arda’s

Decision: A. Look a contact up by ardacloud_user_id, then by email. Stamp it only while blank. A contact holding a different ID is left as it is, and the mismatch is reported. A one-off script stamps existing contacts from arda_oidc_sub before the backfill; the BFF never reads the subject.

DQ-005: Which subscription statuses count as paying?

Section titled “DQ-005: Which subscription statuses count as paying?”

Context: HubSpot subscriptions are active, unpaid, past_due, scheduled, canceled or expired. Some unpaid subscriptions never completed a first payment. A scheduled subscription is one sales have signed that hasn’t started; 7 of them belong to customers Stripe still bills while their billing moves to HubSpot.

OptionDescriptionTrade-offs
Aactive onlyStrict; a failed renewal cuts features immediately
Bactive, unpaid, past_dueA grace period while a failed payment is sorted out; a customer whose subscription hasn’t started, including one Stripe still bills, reads as unpaid
CAs B, but unpaid only after at least one completed paymentGrace for renewals only; one more property to read
DAs B, plus scheduledA signed customer has features before the first charge; mid-move customers stay Paid with no special handling, and a scheduled subscription blocks a second in-app purchase

Decision: D. A tenant drops to Personal only once its subscriptions are canceled or expired. HubSpot’s MRR reports count active only, so they leave out tenants in dunning or not yet started; the difference is intended.

Context: Arda sells three tiers with SKUs arda-solo, arda-pro and arda-team. Many other products (Arda Standard, per-card plans, older plans) have active subscriptions, and some line items carry no SKU. The accounts service tracks only plan.

OptionDescriptionTrade-offs
ARead each paying subscription’s tier from its SKU, highest winsReady for tier enforcement; needs every product mapped and every line item to carry a SKU, and a second concept beside plan in the backend
BNo: any paying subscription makes the tenant Paid, whatever its productOne concept in the backend; the tier is HubSpot’s contract with the customer, read there by sales

Decision: B. The lead endpoint still takes a tier, because the quote needs a product; nothing in Arda reads it back.

DQ-007: How is the webhook authenticated, and what does it trust?

Section titled “DQ-007: How is the webhook authenticated, and what does it trust?”

Context: The webhook writes through a HubSpot service key in accounts, which may change any tenant’s plan. HubSpot workflow webhooks can send an API key in a header. The existing signup webhook already shares a key with HubSpot.

OptionDescriptionTrade-offs
ATrust the payload’s tenant ID and statusFewest calls; anyone with the key could set any tenant’s plan
BRead only a subscription ID; fetch everything else from HubSpot and recomputeA forged call can only cause a recomputation; repeats and late calls arrive at the latest answer; a few more HubSpot calls

Decision: B, authenticated by the existing signup key in the arda_signup_secret_key header, compared in constant time, with no query-string form. Only the subscription workflow calls it, so the ID is always a subscription’s: HubSpot IDs are unique only within an object type, and accepting deal IDs as well would make one ID ambiguous.

DQ-008: How do non-production partitions use HubSpot?

Section titled “DQ-008: How do non-production partitions use HubSpot?”

Context: There’s one production HubSpot account, and every partition already shares its other HubSpot credentials. Opening deals from dev would put test leads on the real sales board. A standard sandbox copies properties and pipelines, but can’t take payments.

OptionDescriptionTrade-offs
AProduction only; other partitions leave the flow off (a condition on the partition in the template)No test environment; the template treats one partition as special
BA sandbox for non-production, with each partition’s values in its own vaultOne template for every partition; the flow runs in dev end to end, except payment

Decision: B. Each partition’s HubSpot-Subscriptions vault item holds its key, quote template, quote domain and payment type. NONE makes quotes unpayable.

DQ-009: Can an in-app upgrade change a paying tenant’s tier?

Section titled “DQ-009: Can an in-app upgrade change a paying tenant’s tier?”

Context: Changing a live subscription’s tier involves proration and a second subscription, which the quote flow doesn’t handle.

OptionDescriptionTrade-offs
AAllow it: open another quoteA second subscription; double billing until someone cancels one
BRefuse in-app (409); sales change the tierCorrect billing; a manual step

Decision: B, until proration is designed.

DQ-010: When does an upgrade reuse a deal?

Section titled “DQ-010: When does an upgrade reuse a deal?”

Context: A user can click Upgrade several times, change their mind about the tier, or come back after a lost deal.

OptionDescriptionTrade-offs
AA new deal every timeDuplicates on the board
BReuse the tenant’s open checkout deal, replacing its tier and unpaid quote; open a new deal when it’s closedOne live deal per tenant, once duplicates converge (DQ-012); a returning prospect appears as a fresh lead

Decision: B. “Closed” is HubSpot’s own closed flag on the stage. Search lag means a request seconds after another can miss its deal; DQ-012 is what brings the two back to one.

Context: The accounts service’s PlanType is Personal | Paid.

OptionDescriptionTrade-offs
AAdd values per tierArda could act on tiers; an accounts change, and a second copy of HubSpot’s contract
BKeep Personal / PaidNo accounts change; the tier stays in HubSpot (DQ-006)

Decision: B.

DQ-012: How are concurrent upgrades kept to one checkout?

Section titled “DQ-012: How are concurrent upgrades kept to one checkout?”

Context: Two upgrade requests close together (a double click, two tabs, two admins of one tenant, or a retry during search lag) can each find no open deal and open one, or each withdraw the old quote and publish a new one. If both quotes are paid, the tenant is billed twice. The BFF is stateless, so it can’t lock per tenant. HubSpot has no rule limiting a pipeline to one open deal per record, and a unique property applies across every deal, not just one pipeline’s.

OptionDescriptionTrade-offs
AA unique deal property carrying the tenant ID while its checkout deal is open, so HubSpot refuses a secondPrevents duplicate deals at creation; a new unique property, and code moving it off closed deals
BConverge afterwards, touching only the Subscriptions pipelineNo new property, and deals in other pipelines are never touched; duplicates’ quotes are live until they converge
CAccept it, relying on the upgrade button disabling itself, and sales refunding a double paymentNo code; nothing converges

Decision: B, in three places:

  • After publishing a quote: keep the newest of the BFF’s published, unpaid quotes on that deal. A link answered earlier may stop working; clicking again returns the live one.
  • On each upgrade: first, if any of the tenant’s open checkout deals has a paid or processing quote, cancel nothing, publish nothing, and answer 409, because the payment’s subscription may not be linked yet. Otherwise keep the oldest open checkout deal and cancel the rest.
  • When a subscription’s payment arrives: withdraw the BFF’s unpaid quotes on the tenant’s other checkout deals, open or cancelled, and cancel the open ones, which stops a second payment. Mark its own checkout deal Subscribed, but only once the subscription’s tenant ID is searchable: until then, that open deal’s paid quote is what makes the next upgrade answer 409.

Only deals and quotes the flow itself created, in the Subscriptions pipeline, are ever moved or withdrawn; a deal with a paid or processing quote is never cancelled, and a draft is never deleted by another request. The remaining window, before the first payment, is accepted, and sales refund a double payment.

DQ-013: How does a subscription get its tenant?

Section titled “DQ-013: How does a subscription get its tenant?”

Context: A subscription started by an in-app quote is linked to the checkout deal, which carries the tenant’s ID. Sales keep selling outside the app, on deals they can set the tenant ID on. Subscriptions sold before this design have no such deal, but their contacts carry ardacloud_user_id once stamped (DQ-004), which leads to an Arda user account and its memberships. Contacts also carry arda_tenant_eid, one tenant per person, written by the campaign product projector under that tool’s own rules. Some contacts have no Arda account. Most existing subscriptions were sold through the HubSpot sign-up, which gave each buyer two tenants: a Personal one, and a company tenant created as Paid.

OptionDescriptionTrade-offs
ABackfill: people and scripts write the tenant ID onto existing customers’ dealsOne-off effort; writes into sales’ pipelines; a new subscription still needs its own path
BThe webhook stamps it on a subscription’s first event: from any of its deals carrying a tenant ID, else from its contacts’ Arda memberships when exactly one tenant fits; otherwise left unlinked and reportedExisting, in-app and sales-led subscriptions take the same path, now and as they’re sold; ambiguous cases wait for a person or a script
DThe webhook copies only a checkout deal’s tenant ID; a reviewed backfill script stamps every other subscription, and a verify script checks every tenant’s plan against its subscriptionsSimplest webhook, and every inference is reviewed by a person; sales stamp what they sell afterwards, and the periodic verify run lists what they miss
CAs B, but resolving from the contact’s arda_tenant_eidAlways yields an answer; depends on another tool’s resolution rules, and picks one tenant for people with several

Decision: D (Round 3, replacing B). Inferring a tenant inside the webhook put unreviewed guesses on the path that sets plans, and spread linking rules across the webhook, the sign-up hook and a script; D keeps the webhook to the one link it creates itself. The webhook links a subscription only once it’s paying (scheduled included), and waits until the stamp is searchable, so the next call for that tenant can’t compute a plan without it. The backfill script decides from sales-led deals’ tenant IDs, contacts’ user accounts and their tenants, and, for HubSpot sign-up buyers, the company tenant the sign-up created as Paid; it writes nothing it can’t decide, and can be run again at any time. The subscription workflow re-enrolls when ardacloud_tenant_id changes, so any stamp sets the plan without its writer calling the webhook. The verify script starts from every Arda tenant, so a tenant still Paid after its last subscription ended is listed; the in-app Upgrade is switched on only once it reports nothing. Until the HubSpot sign-up is retired, its hook keeps stamping its buyers’ subscriptions, since it’s the one moment that knows both sides. The contact’s arda_tenant_eid can serve as a cross-check in the verify script, not as the source. After stamping, the subscription itself is the only place the tenant is read from.



Copyright: (c) Arda Systems 2025-2026, All rights reserved