Decision Log: HubSpot Subscriptions
Author: Michael Probber Created: 2026-10-20
Purpose
Section titled “Purpose”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.
Decision Table
Section titled “Decision Table”| # | Question | Status | Decision | Round |
|---|---|---|---|---|
| DQ-001 | What decides whether a tenant pays? | Decided | HubSpot subscription status | R1 |
| DQ-002 | How does a buyer pay? | Decided | A quote on a checkout deal | R1 |
| DQ-003 | Where does the link between HubSpot and Arda live? | Decided | Tenant ID on each subscription (and on checkout deals); user-account ID on the contact | R2 |
| DQ-004 | Which contact property identifies the person? | Decided | A new ardacloud_user_id holding UserAccount.eId, written only while blank | R2 |
| DQ-005 | Which subscription statuses count as paying? | Decided | active, unpaid, past_due, scheduled | R2 |
| DQ-006 | Does Arda read the tier? | Decided | No: any paying subscription is Paid | R2 |
| DQ-007 | How is the webhook authenticated, and what does it trust? | Decided | Key header; subscription ID only; recompute | R1 |
| DQ-008 | How do non-production partitions use HubSpot? | Decided | A sandbox, through each partition’s own vault | R1 |
| DQ-009 | Can an in-app upgrade change a paying tenant’s tier? | Decided | No: 409, and sales change it | R1 |
| DQ-010 | When does an upgrade reuse a deal? | Decided | Open in-app deal reused; closed never | R1 |
| DQ-011 | Does the plan enum grow? | Decided | No: Personal / Paid; the tier stays in HubSpot | R2 |
| DQ-012 | How are concurrent upgrades kept to one checkout? | Decided | Converge afterwards, in the Subscriptions pipeline only | R1 |
| DQ-013 | How does a subscription get its tenant? | Decided | In-app: copied from the checkout deal by the webhook. Otherwise: a reviewed backfill script, checked by a verify script | R3 |
Round 1: Design Decisions
Section titled “Round 1: Design Decisions”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.
| Option | Description | Trade-offs |
|---|---|---|
| A | The deal’s stage: closed won is paid | Simple; but a person must move the deal when a payment lands, and a failed card or cancellation isn’t reflected until someone notices |
| B | The subscription’s status | Follows 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.
DQ-002: How does a buyer pay?
Section titled “DQ-002: How does a buyer pay?”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.
| Option | Description | Trade-offs |
|---|---|---|
| A | One shared payment link per tier | No code; but payments land on whichever contact the typed email matches, and someone must attach each to a tenant’s deal by hand |
| B | A payment link created per deal | A plain checkout page; attaching it to the deal relies on undocumented association behaviour |
| C | A quote per deal, payment enabled | Documented 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.
DQ-003: Where does the link between HubSpot and Arda live?
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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Store HubSpot IDs in Arda (user account, tenant) | Direct lookups; needs accounts changes, and a second copy of the mapping that can drift |
| B | Tenant ID on the subscription’s deals; a tenant’s subscriptions are those on its deals | Works 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 |
| C | Tenant ID on the subscription itself, and on the checkout deal so a new subscription can take it; the user-account ID on the contact | A 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | A new ardacloud_user_id property holding UserAccount.eId | Arda’s own identifier, matching ardacloud_tenant_id; existing contacts need it stamped once from their subject |
| B | arda_oidc_sub, written by the BFF under the same blank-only rule | No 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | active only | Strict; a failed renewal cuts features immediately |
| B | active, unpaid, past_due | A grace period while a failed payment is sorted out; a customer whose subscription hasn’t started, including one Stripe still bills, reads as unpaid |
| C | As B, but unpaid only after at least one completed payment | Grace for renewals only; one more property to read |
| D | As B, plus scheduled | A 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.
DQ-006: Does Arda read the tier?
Section titled “DQ-006: Does Arda read the tier?”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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Read each paying subscription’s tier from its SKU, highest wins | Ready for tier enforcement; needs every product mapped and every line item to carry a SKU, and a second concept beside plan in the backend |
| B | No: any paying subscription makes the tenant Paid, whatever its product | One 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Trust the payload’s tenant ID and status | Fewest calls; anyone with the key could set any tenant’s plan |
| B | Read only a subscription ID; fetch everything else from HubSpot and recompute | A 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Production 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 |
| B | A sandbox for non-production, with each partition’s values in its own vault | One 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Allow it: open another quote | A second subscription; double billing until someone cancels one |
| B | Refuse in-app (409); sales change the tier | Correct 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | A new deal every time | Duplicates on the board |
| B | Reuse the tenant’s open checkout deal, replacing its tier and unpaid quote; open a new deal when it’s closed | One 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.
DQ-011: Does the plan enum grow?
Section titled “DQ-011: Does the plan enum grow?”Context: The accounts service’s PlanType is Personal | Paid.
| Option | Description | Trade-offs |
|---|---|---|
| A | Add values per tier | Arda could act on tiers; an accounts change, and a second copy of HubSpot’s contract |
| B | Keep Personal / Paid | No 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | A unique deal property carrying the tenant ID while its checkout deal is open, so HubSpot refuses a second | Prevents duplicate deals at creation; a new unique property, and code moving it off closed deals |
| B | Converge afterwards, touching only the Subscriptions pipeline | No new property, and deals in other pipelines are never touched; duplicates’ quotes are live until they converge |
| C | Accept it, relying on the upgrade button disabling itself, and sales refunding a double payment | No 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Backfill: people and scripts write the tenant ID onto existing customers’ deals | One-off effort; writes into sales’ pipelines; a new subscription still needs its own path |
| B | The 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 reported | Existing, 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 |
| D | The 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 subscriptions | Simplest webhook, and every inference is reviewed by a person; sales stamp what they sell afterwards, and the periodic verify run lists what they miss |
| C | As B, but resolving from the contact’s arda_tenant_eid | Always 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
Copyright: © Arda Systems 2025-2026, All rights reserved