Design: HubSpot Subscriptions
Author: Michael Probber
Created: 2026-10-20
Status: Draft. The implementation in review (#1447) predates this revision and is being updated to match it.
Repositories: arda-frontend-app (#1447), infrastructure (#548)
Overview
Section titled “Overview”Arda sells subscriptions in three tiers (Solo, Pro and Team) through HubSpot, which takes payment through Stripe and records each sale as a HubSpot subscription. The tier is HubSpot’s contract with the customer; Arda tracks only whether a tenant pays, as its plan, and any tier is Paid. Today the only path from a sale to a plan is the HubSpot sign-up, which is being deprecated: it creates the buyer’s company tenant as Paid, and nothing changes that plan afterwards, when a payment fails or the subscription ends. Arda is going to restrict features to paying tenants, so the plan has to follow what HubSpot bills: a tenant recorded on the wrong plan loses features it pays for, or keeps features it no longer does.
Each subscription carries the ID of the tenant it pays for (ardacloud_tenant_id), and a tenant’s plan is worked out from the subscriptions carrying its ID, and nothing else (DQ-003). The design has two halves, both in @arda-cards/bff:
- Selling. A signed-in user picks a tier. The BFF finds or creates their HubSpot contact, opens or reuses the tenant’s checkout deal in a dedicated Subscriptions pipeline, and publishes a quote for the tier on it. HubSpot only publishes a quote that’s linked to a deal, and links the payment, and the subscription it starts, to the quote’s deal, whatever email the buyer pays with (DQ-002). The app writes the tenant ID on that deal, so the new subscription can take it from there.
- Recording. A HubSpot workflow calls a BFF webhook whenever a subscription’s status or tenant ID changes. If a paying subscription was sold in-app and doesn’t carry a tenant ID yet, the BFF copies it from the checkout deal. Every other subscription is stamped by a backfill script and checked by a verify script; the webhook never infers a tenant (DQ-013). It then recomputes whether that tenant pays from all of its subscriptions, and sets the tenant’s
planin the accounts service with a dedicated HubSpot service key (DQ-001, DQ-007).
HubSpot holds every link between the two systems: the tenant’s ID on its subscriptions (and on checkout deals), and the person’s user-account ID on their contact. Arda stores no HubSpot IDs. Production uses the production HubSpot account; every other partition uses a HubSpot sandbox, configured through each partition’s own 1Password vault (DQ-008).
Decision Summary
Section titled “Decision Summary”| # | Decision | Chosen Option |
|---|---|---|
| DQ-001 | What decides whether a tenant pays? | HubSpot subscription status, not deal stage |
| DQ-002 | How does a buyer pay? | A quote on a checkout deal, not a shared payment link per tier |
| DQ-003 | Where does the link between HubSpot and Arda live? | The tenant ID on each subscription (and on checkout deals, for new sales); the user-account ID on the contact |
| DQ-004 | Which contact property identifies the person? | A new ardacloud_user_id holding UserAccount.eId, written only while blank |
| DQ-005 | Which subscription statuses count as paying? | active, unpaid, past_due, scheduled |
| DQ-006 | Does Arda read the tier? | No: any paying subscription is Paid; the tier is HubSpot’s contract |
| DQ-007 | How is the webhook authenticated, and what does it trust? | Signup-key header; reads only a subscription ID; recomputes from HubSpot |
| DQ-008 | How do non-production partitions use HubSpot? | A standard sandbox, chosen per partition through its own vault |
| DQ-009 | Can an in-app upgrade change a paying tenant’s tier? | No: 409, and sales change it |
| DQ-010 | When does an upgrade reuse a deal? | An open checkout deal is reused; a closed one never is |
| DQ-011 | Does the plan enum grow? | No: Personal and Paid only |
| DQ-012 | How are concurrent upgrades kept to one checkout? | They converge afterwards, within the Subscriptions pipeline only |
| DQ-013 | How does a subscription get its tenant? | In-app sales: copied from the checkout deal on the first paying event. Everything else: a reviewed backfill script, checked by a verify script. The webhook never infers one |
Full rationale in Decision Log: HubSpot Subscriptions.
Platform Conventions
Section titled “Platform Conventions”| Pattern | Applies to |
|---|---|
| Frontend Application Architecture | Both endpoints are BFF endpoints in @arda-cards/bff, declared in its endpoint table and served by its route dispatcher |
| Secret Delivery Pattern | The HubspotSubscription partition secret: 1Password → AMM → CloudFormation NoEcho parameter → Secrets Manager |
| Secrets Vault | The HubSpot-Subscriptions item, kept under the same name in each partition’s OAM vault |
Deliberate departures
| Departure | Pattern | Why | Agreed with |
|---|---|---|---|
| The webhook calls ARDA under a HubSpot service key rather than a user’s session | Frontend Application Architecture (the BFF forwards the user’s token) | No user is present on a HubSpot webhook. The key is its own service identity in accounts, with only the permissions the webhook needs, rather than the SaaS-admin ARDA_API_KEY | Michael Probber, Denis |
HubSpot Accounts
Section titled “HubSpot Accounts”| Production | Sandbox | |
|---|---|---|
| Account | 242334114 | 247746885 (standard sandbox) |
| Used by | prod | dev, stage, demo |
| Payments | Stripe (BYO_STRIPE) | None: HubSpot does not support payments in sandboxes |
| Quote template | New Modern (830399227610) | Default Modern (876811118281) |
| Quote domain | help.arda.cards | 247746885.hs-sites-na2.com |
| Webhook targets | https://live.app.arda.cards | https://dev.app.arda.cards, https://stage.app.arda.cards, https://demo.app.arda.cards |
What the HubSpot sandbox copies from the production HubSpot account: contact and deal properties (including the deal’s ardacloud_tenant_id), the deal pipelines with the same pipeline and stage IDs, and contact records. It doesn’t copy products, custom quote templates, or properties created after it was. The three tier products were created in the sandbox with production’s names, SKUs, prices and billing periods. The subscription property ardacloud_tenant_id and the contact property ardacloud_user_id have to be created in both accounts (see Configuration). This copying is HubSpot’s alone: Arda’s partitions share no data, and dev’s accounts service holds nothing from production.
Because the sandbox can’t take payments, a partition whose paymentType is NONE publishes quotes with payment turned off. A buyer can view, download and sign them, but can’t pay. So the sandbox has no subscriptions of its own, and no HubSpot environment can test the paid path: it’s covered by stubbed tests (see Testing Strategy).
The sandbox’s copied contacts are real production people with no Arda user in dev. Their ardacloud_user_id is blank, so a dev user whose email matches one is found by email and stamped with their dev user ID. That’s expected in a sandbox.
The Subscriptions Pipeline
Section titled “The Subscriptions Pipeline”In-app upgrades open their checkout deals in a dedicated Subscriptions deal pipeline (2633316046), which both accounts share by ID. A checkout deal exists because HubSpot publishes a quote only on a deal; it carries the tenant’s ID so the subscription the quote starts can take it, and it’s sales’ record of who started a checkout.
| Stage | ID | Probability | Meaning |
|---|---|---|---|
| Checkout Started | 4463862476 | 20% | Opened by an in-app upgrade; the quote is out |
| In Conversation | 4463862477 | 40% | Sales are following up |
| Subscribed | 4463862478 | 100%, closed won | The sale closed |
| Cancelled | 4463862482 | 0%, closed lost | It didn’t, or it ended |
Stages don’t decide anything in Arda. A tenant’s plan comes from its subscriptions (DQ-001); the stage is sales’ record of the deal. The BFF reads a stage only to tell whether a checkout deal is still open, using HubSpot’s own closed flag (DQ-010), and it moves a stage in two cases: the webhook marks a checkout deal Subscribed once its subscription is linked and searchable, and it cancels a duplicate open checkout deal (DQ-012).
Deals in other pipelines are never touched. Existing customers’ deals stay in the pipelines they were sold in (Product Led Growth, Sales Led Growth, Renewal from Subscriptions). The BFF neither reads nor writes them. Their subscriptions are linked by the backfill script (see Existing Subscriptions).
Structural Design
Section titled “Structural Design”How HubSpot and Arda connect
Section titled “How HubSpot and Arda connect”A subscription maps to an Arda tenant through its own ardacloud_tenant_id = Tenant.eId, and that’s the only path a tenant’s plan is computed along. A contact maps to an Arda user account through ardacloud_user_id = UserAccount.eId. A checkout deal carries ardacloud_tenant_id only so that the subscription its quote starts can take it.
| HubSpot | Arda | Written by |
|---|---|---|
Subscription.ardacloud_tenant_id (not unique) | Tenant.eId | The webhook, from the checkout deal; the HubSpot sign-up hook, until it’s retired; sales, on subscriptions they sell; the backfill script, for every other (DQ-013) |
Deal.ardacloud_tenant_id (not unique) | Tenant.eId | The BFF, on every checkout deal it opens |
Contact.ardacloud_user_id (unique) | UserAccount.eId | The BFF (while blank); a one-off script for existing contacts (see Existing Subscriptions) |
Subscription.hs_status | Tenant.plan | HubSpot billing; the webhook carries it into Arda |
ardacloud_tenant_id isn’t unique: a tenant can have several subscriptions and several checkout deals over time. ardacloud_user_id is unique because one person is one contact. The line items’ SKUs name the tier, which stays in HubSpot: Arda doesn’t read it (DQ-006).
Determining Whether a Tenant Has a Subscription
Section titled “Determining Whether a Tenant Has a Subscription”A tenant’s plan is derived afresh each time, from HubSpot alone:
- Find the tenant’s subscriptions: every subscription whose
ardacloud_tenant_idis the tenant’s ID. Search finds their IDs; their current statuses are then read directly, in one batch read, because HubSpot’s search index lags a few seconds behind any change, a status as much as a stamp. A call that has just stamped a subscription also waits until the search returns it (see Linking a subscription to its tenant), so calls that follow one another always see earlier stamps. The subscription that triggered a webhook call is always counted, from its own direct read, even before search returns it: a stamp written by the backfill, sales or the sign-up hook triggers the call within seconds, and a plan computed without it would take a paying tenant toPersonal. - Plan:
Paidif any of them has statusactive,unpaid,past_dueorscheduled, whatever product it’s for; otherwisePersonal(DQ-005, DQ-006).canceledandexpireddon’t count.
Deals and tiers play no part in this.
Counting unpaid and past_due as paying gives a tenant a grace period while a failed payment is sorted out. Counting scheduled gives a customer sales have signed their features before the first charge, and covers customers still billed in Stripe while their HubSpot subscription waits to start. HubSpot’s MRR reports and the “Arda — Active Subscriptions” list count active only, so Arda’s Paid tenants include subscriptions those reports leave out (9 unpaid and 7 scheduled as of 2026-10-21).
Checked against production data:
| Subscription | Result |
|---|---|
| active Arda Solo | Paid |
| active Arda Standard | Paid |
| active Team per-card | Paid |
| unpaid “Arda Pro - 2 Power Users” | Paid |
| scheduled Arda Pro | Paid |
How Arda Reads It
Section titled “How Arda Reads It”The result is stored on the tenant in the accounts service, as Tenant.plan (Personal | Paid). Arda stores no tier (DQ-011).
- Reading: a user’s active tenant is the app’s current tenant, kept in step with the session’s Cognito
custom:tenantclaim, and its plan is on the tenant record (GET /v1/tenant/tenant/{id}), which any member of the tenant can read. Nothing in the app gates features onplanyet. Company settings sends the currentplanback unchanged when it saves the tenant. - Writing: the accounts service decides which fields a caller may change. A tenant admin’s update keeps the stored
planand takes only the name, company and settings, so a user can’t make their own tenant paid, whatever they send. The webhook writesplanunder the HubSpot service key, a service identity of its own in accounts (besideArdaApiKeyand the Cognito post-confirmation key). That key may read a tenant, and change itsplanand nothing else.
Behavioral Design
Section titled “Behavioral Design”Selling: the lead endpoint
Section titled “Selling: the lead endpoint”The tenant is the session’s verified custom:tenant claim, so the BFF needs no call to confirm it. It reads the user’s own account with the user’s token, because the token doesn’t carry UserAccount.eId, which the contact is stamped with. It then answers 409 if the tenant already pays. Otherwise it finds or creates the contact, reuses or opens the checkout deal, puts the tier on it, and publishes a quote whose link the app opens.
The app calls the endpoint when the user clicks a tier and sends them straight to the returned checkout page. It never renders checkout links ahead of time: a deal holds one live quote, so publishing one tier’s quote retires any earlier one, and a user who comes back for a different tier simply gets a new quote.
POST /bff/hubspot/subscription-lead
- Authentication: the user’s session, as for every non-public BFF endpoint.
- Request:
{ "tier": "arda-solo" | "arda-pro" | "arda-team" }. The tenant is always the app’s current active tenant, the session’scustom:tenant; the body can’t name one. - Response:
{ "contactId", "dealId", "checkoutUrl" }. - Errors:
400: not one of the three tiers.401: no session.404: no user account for the session.409: the tenant already pays, including a subscriptionscheduledto start; tier changes go through sales (DQ-009). Also when the tenant’s checkout deal has a paid or processing quote: the payment is being recorded (DQ-012).503: HubSpot isn’t configured in this partition.
Steps, with their rules:
- Contact. Find it by
ardacloud_user_id, else by email; otherwise create it by email in one upsert call, so two clicks make one contact. Then read the contact’s current user ID, whichever way it was found, and stampardacloud_user_idwith the user account’seIdonly while it’s blank. If it already holds a different ID, leave it and report the mismatch to Sentry (DQ-004). - Deal. Look at the tenant’s open checkout deals in the Subscriptions pipeline. If any of them has a paid or processing quote, stop there: cancel nothing, publish nothing, and answer 409. A payment is landing or has just landed, and its subscription may not carry the tenant’s ID yet, so the paying check above can’t see it. Otherwise, if there’s more than one, keep the oldest and cancel the rest (DQ-012). Reuse the kept deal and add the contact to it. If there’s none, open a new deal, named “Arda subscription: ‹user’s email›”, in Checkout Started with
ardacloud_tenant_idset (DQ-010). - Tier. Replace the deal’s line items with one for the tier’s product, found by SKU, so sales see what’s being bought.
- Quote. Delete any earlier quote the BFF published on this deal that hasn’t been paid. Drafts are left alone, because a draft belongs to a request still publishing it. Quotes are recognised by their “Arda subscription” title prefix. Then create the quote as a draft (with
hs_languageset, which HubSpot requires until a template is attached), add its line item, and link the template, deal, line item and contact. Publish it with the partition’s quote domain and a random 20-character slug. The link ishttps://‹domain›/‹slug›. WithpaymentTypeNONE, payment is off. - One quote per deal. After publishing, keep the newest of the BFF’s published, unpaid quotes on the deal, delete the others, and answer the survivor’s link. Of two upgrades at once, the later-published quote wins. If the first request has already answered, its link stops working, and clicking Upgrade again returns the live one; that’s accepted, since it needs two requests in flight and costs no payment (DQ-012). A quote still being published is left to the request publishing it, which runs the same step itself.
Recording: the webhook
Section titled “Recording: the webhook”A HubSpot workflow posts the ID of a subscription whose status or tenant ID changed. If the subscription is paying and doesn’t carry a tenant ID yet, the BFF copies one from its checkout deal; a subscription with no checkout deal is left for the backfill script. It then recomputes that tenant’s plan from all of its subscriptions, counting the triggering one from its direct read, and writes the plan with the HubSpot service key if it differs. Each call derives the answer from HubSpot’s current state, so a retried, repeated or late call arrives at the same answer as the latest one.
POST /bff/hubspot/subscription-changed
-
Authentication: the
arda_signup_secret_keyheader (the dash spelling is accepted too), compared in constant time withARDA_SIGNUP_SECRET_KEY. The endpoint is public, so no session is needed. There’s no query-string form. -
Request: a workflow webhook body. Only the subscription’s ID is read, from
objectId,hs_object_idorproperties.hs_object_id. It is always a subscription ID: the endpoint is called only by the subscription workflow. -
Response:
{ "tenantId", "plan" }once linked;{ "linked": false, "reason" }when the subscription is unlinked and either isn’t paying or has no single checkout deal tenant. -
Errors:
401: missing or wrong key.400: no numeric ID.404: no such subscription, or the tenant it carries doesn’t exist in this partition.503: not configured, or a subscription it just stamped hasn’t appeared in search within a few seconds; HubSpot retries.
An unlinked subscription is answered with
200andlinked: false, not an error, because HubSpot would retry an error and the answer wouldn’t change.
Linking an in-app sale to its tenant (DQ-013), on its first paying event. Once linked, a subscription is recomputed on every event, paying or not.
- Its checkout deal. If its deals in the Subscriptions pipeline carry
ardacloud_tenant_id, and all name the same tenant, use it. If they name different tenants, report it to Sentry and don’t guess. - Nothing else. A subscription with no checkout deal (sold by sales, or before this design), or one that has ended (
canceled,expired), is answeredlinked: falseand left for the backfill script, and the verify script lists it until it’s linked (see Existing Subscriptions). The webhook never infers a tenant from contacts or from deals in other pipelines. - Stamp it. Write the tenant ID onto the subscription, then search the tenant’s subscriptions until the stamp appears, for at most a few seconds; otherwise answer 503 so HubSpot retries. Without the wait, the next call for the same tenant could miss this subscription and write a plan computed without it. From then on, the subscription is all the webhook reads.
Closing the checkout. When the subscription is paying, the webhook moves its own checkout deal to Subscribed. It does so only after the subscription’s tenant ID is searchable: until then the lead endpoint’s 409 rests on that open deal’s paid quote, and closing it earlier would let another upgrade publish a second payable quote (DQ-012). Nothing else closes a checkout deal as won.
One open checkout deal per tenant. When the subscription is paying, the webhook also withdraws the BFF’s unpaid quotes on every other checkout deal of the tenant’s in the Subscriptions pipeline, open or already cancelled, and moves the open ones to Cancelled. Cancelled deals are included because a request still publishing when its deal was cancelled leaves a live quote there. A paid quote is what stops a second one from being paid, so this is the moment that matters (DQ-012). A deal with a paid or processing quote is never cancelled, and deals in other pipelines are never touched. Until the first payment arrives, two duplicate deals’ quotes are both live: a buyer who pays both has two subscriptions, which sales refund. The app’s upgrade button should disable itself while a request is in flight, which removes the commonest cause, a double click.
What it trusts: only the subscription ID. Every tenant ID and status is read from HubSpot with the service key, and tenant IDs that aren’t UUIDs are dropped. A forged call can at most make the BFF recompute an answer it would reach anyway (DQ-007).
The workflow: one per HubSpot account, subscription-based, triggered when hs_status or ardacloud_tenant_id is known, with re-enrollment when either changes. It covers payments, failures, cancellations and expiries, and a tenant ID written by the backfill script, the sign-up hook or sales, so the plan follows any stamp without the stamper calling the webhook. Each enrollment posts to the endpoint with the key header. In production, it has one webhook action, to prod. In the sandbox, it has three, one per partition it serves (dev, stage and demo). Every partition’s BFF receives every sandbox change, and acts only on tenants its own accounts service holds; the others answer 404 and change nothing.
Signing up from HubSpot
Section titled “Signing up from HubSpot”The HubSpot sign-up, being deprecated, runs the other way round: the buyer pays in HubSpot first, and HubSpot then calls the app’s sign-up hook (/api/webhooks/hubspot), which creates the user account and its tenants. The subscription has no checkout deal, so the webhook won’t link it (DQ-013). Accounts makes the company tenant Paid at sign-up, but nothing could ever take it back to Personal.
So the sign-up hook writes the link, since it’s the one moment that knows both sides. HubSpot sends it the contact’s ID; after creating the account and tenants, it:
- stamps
ardacloud_user_idon that contact; - stamps the new company tenant’s ID on that contact’s unlinked paying subscriptions, skipping any with a checkout deal: the webhook links those.
The stamp re-enrolls the subscription in the workflow, which sets the plan from then on. If the subscription doesn’t exist yet when the hook runs, there’s nothing to stamp; the verify script lists it, and the next backfill run links it. The hook keeps stamping until the HubSpot sign-up is retired.
Existing Subscriptions
Section titled “Existing Subscriptions”Subscriptions not sold in-app have no checkout deal, so the webhook never links them. A backfill script links them all, and a verify script proves it. No deal is created or changed for them.
- Give existing contacts their user ID. A one-off script stamps
ardacloud_user_idon each contact whosearda_oidc_submatches exactly one Arda user account, with that account’seId. That’s the only use of the subject; afterwards the user ID is all the BFF reads. - Bring customers billed outside HubSpot subscriptions into HubSpot. About 30 tenants are billed directly in Stripe or through Zaybra. Each gets a HubSpot subscription record linked to its existing Stripe subscription, with no new charge (the Zaybra cutover in the PLG pipeline spec). The 7 tenants mid-move already have a HubSpot subscription,
scheduledwhile Stripe bills them; it counts as paying. - Switch on the workflow without enrolling existing records. From here on, each stamp re-enrolls its subscription, and the webhook sets the tenant’s plan.
- Backfill. For every paying subscription with no tenant ID, the script decides the tenant from the evidence: the
ardacloud_tenant_idsales set on a sales-led deal; its contacts’ user accounts and the tenants they belong to; and, for a HubSpot sign-up buyer, who has a Personal tenant and a company tenant, the company tenant the sign-up created asPaid. It writes nothing it can’t decide: those are listed with the reason and decided by a person. The result is reviewed before the script writesardacloud_tenant_idon each subscription. It skips subscriptions already linked, so it can be run again at any time. - Verify. Start from every tenant in Arda, not from HubSpot’s subscriptions. The script checks that every paying subscription carries the ID of a tenant that exists, and that each tenant’s stored
planmatches the plan computed from the subscriptions carrying its ID (Personalwhen nothing pays). It lists every exception and writes nothing. Starting from Arda is what catches a tenant stillPaidafter its last subscription ended, including the outcome of the accepted webhook race and old sign-up tenants whose only subscription had ended. Run it once the backfill’s stamps are searchable. - Undo a wrong link. If a subscription’s tenant ID turns out to be wrong, correct it on the subscription; the workflow recomputes the tenant it now names. The webhook doesn’t revisit the tenant it used to name, so recompute that tenant’s plan too, with the same rules, and write it. It may still pay through another subscription, so don’t simply set it to
Personal.
The in-app Upgrade is switched on last, once the verify script reports nothing. The lead endpoint decides “already pays” from the subscriptions carrying the tenant’s ID, so until existing subscriptions are linked, a paying customer would look unpaid and could be sold a second subscription.
After the backfill, sales set the tenant ID on each subscription they sell. The verify script runs periodically and lists any they missed, and the backfill script is run again to link them.
There’s no data to migrate in Arda: every tenant’s plan is written by the webhook from HubSpot’s state.
Configuration
Section titled “Configuration”Both endpoints are switched on by one config, built only when every value is present. Without it, both answer 503.
| Environment variable | Source | prod | sandbox partitions |
|---|---|---|---|
HUBSPOT_SUBSCRIPTION_PIPELINE_ID | Plain value | 2633316046 | 2633316046 |
HUBSPOT_SUBSCRIPTION_CHECKOUT_STAGE_ID | Plain value | 4463862476 | 4463862476 |
HUBSPOT_SUBSCRIPTION_ACCESS_TOKEN | Vault credential | production service key | sandbox service key |
HUBSPOT_SUBSCRIPTION_QUOTE_TEMPLATE_ID | Vault quoteTemplateId | 830399227610 | 876811118281 |
HUBSPOT_SUBSCRIPTION_QUOTE_DOMAIN | Vault quoteDomain | help.arda.cards | 247746885.hs-sites-na2.com |
HUBSPOT_SUBSCRIPTION_PAYMENT_TYPE | Vault paymentType | BYO_STRIPE | NONE |
ARDA_SIGNUP_SECRET_KEY | Existing partition secret | shared | shared |
| HubSpot service key (name pending) | New partition secret, issued by accounts | per partition | per partition |
The vault values come from an API Credential item named HubSpot-Subscriptions in each partition’s OAM vault (Arda-ProdOAM, Arda-DevOAM, Arda-StageOAM, Arda-DemoOAM). AMM turns it into the HubspotSubscription partition secret, following the Secret Delivery Pattern; only the vault items differ between partitions (DQ-008). The partitions’ Amplify apps aren’t managed by AMM, so the variables are set on each app in the Amplify console and passed into the server environment at build time.
HubSpot setup, in each account:
- A subscription property
ardacloud_tenant_id: single-line text, not unique. - A contact property
ardacloud_user_id: single-line text, unique. - A service key with these scopes:
crm.objects.contacts.read/.writecrm.objects.deals.read/.writecrm.objects.line_items.read/.writecrm.objects.quotes.read/.writecrm.objects.products.readcrm.objects.subscriptions.read, plus the subscriptions write scope, to stamp the tenant ID
Testing Strategy
Section titled “Testing Strategy”- Unit tests, with HubSpot and ARDA stubbed:
- the client: property names on the wire,
unpaidcounted as paying, quote publishing (domain, slug, payable or not), quote-before-line-item ordering, and one quote kept per deal; - the lead handler: session tenant only, the blank-only stamp and mismatch report (including after an upsert), checkout-deal reuse and duplicate cancellation within the pipeline only, 409 for a paying tenant;
- the webhook handler: key check; a subscription linked from its checkout deal’s tenant ID, a 503 when a stamp doesn’t become searchable, and not linked with no checkout deal, conflicting checkout deals, or when not paying; the plan from the subscriptions carrying the tenant’s ID, whatever their product, counting the triggering one before search returns it; the checkout deal marked Subscribed only once its stamp is searchable; return to Personal; no write when unchanged; duplicate checkout deals cancelled only on a paying subscription.
- the client: property names on the wire,
- Lead flow against HubSpot: run against production HubSpot with dev ARDA (a payable Stripe quote), and against the sandbox (an unpayable quote). Covered contact reuse and the mismatch path, deal reuse, tier switching, and a bad tier (400). The test records were deleted afterwards.
- Webhook against dev ARDA, with HubSpot’s API stubbed: a dev tenant went
Personal→Paid, a repeated call wrote nothing, and cancelling the subscription took it back toPersonal. To be repeated for this revision’s linking rules. - The paid path end to end can’t be tested in any HubSpot environment, because a sandbox can’t take payments. It rests on the stubbed runs, plus the first real sale after launch.
Open Items
Section titled “Open Items”- Rotate the signup key and stop sending it in a query string: PDEV-2201. The signup workflow sends it in the URL today; these webhooks only accept the header.
- Issue the HubSpot service key in accounts (Denis), with the permissions under How Arda Reads It, and deliver it to each partition as a partition secret.
- Set the Amplify variables: each partition’s app is configured in the Amplify console, not by AMM.
- A switch for the Upgrade button, separate from the HubSpot config: one config turns on both endpoints, but the app must not offer Upgrade until the verify script reports nothing (see Existing Subscriptions).
- Sales stamp the subscriptions they sell:
ardacloud_tenant_idon each new sales-led subscription, as part of closing the deal. - Map products to permissions: the end goal is to map every product HubSpot sells to a single identifier in accounts that holds the tenant’s permission set. Until then, any paying product is
Paid. - Retire the HubSpot sign-up path: once its buyers’ subscriptions are linked, the webhook owns their plan, and sign-up no longer needs to create a
Paidtenant. - Tier changes for paying tenants go through sales (409 in-app) until proration is designed.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved