Skip to content

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)

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 plan in 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).

#DecisionChosen Option
DQ-001What decides whether a tenant pays?HubSpot subscription status, not deal stage
DQ-002How does a buyer pay?A quote on a checkout deal, not a shared payment link per tier
DQ-003Where 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-004Which contact property identifies the person?A new ardacloud_user_id holding UserAccount.eId, written only while blank
DQ-005Which subscription statuses count as paying?active, unpaid, past_due, scheduled
DQ-006Does Arda read the tier?No: any paying subscription is Paid; the tier is HubSpot’s contract
DQ-007How is the webhook authenticated, and what does it trust?Signup-key header; reads only a subscription ID; recomputes from HubSpot
DQ-008How do non-production partitions use HubSpot?A standard sandbox, chosen per partition through its own vault
DQ-009Can an in-app upgrade change a paying tenant’s tier?No: 409, and sales change it
DQ-010When does an upgrade reuse a deal?An open checkout deal is reused; a closed one never is
DQ-011Does the plan enum grow?No: Personal and Paid only
DQ-012How are concurrent upgrades kept to one checkout?They converge afterwards, within the Subscriptions pipeline only
DQ-013How 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.


PatternApplies to
Frontend Application ArchitectureBoth endpoints are BFF endpoints in @arda-cards/bff, declared in its endpoint table and served by its route dispatcher
Secret Delivery PatternThe HubspotSubscription partition secret: 1Password → AMM → CloudFormation NoEcho parameter → Secrets Manager
Secrets VaultThe HubSpot-Subscriptions item, kept under the same name in each partition’s OAM vault

Deliberate departures

DeparturePatternWhyAgreed with
The webhook calls ARDA under a HubSpot service key rather than a user’s sessionFrontend 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_KEYMichael Probber, Denis

ProductionSandbox
Account242334114247746885 (standard sandbox)
Used byproddev, stage, demo
PaymentsStripe (BYO_STRIPE)None: HubSpot does not support payments in sandboxes
Quote templateNew Modern (830399227610)Default Modern (876811118281)
Quote domainhelp.arda.cards247746885.hs-sites-na2.com
Webhook targetshttps://live.app.arda.cardshttps://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.

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.

StageIDProbabilityMeaning
Checkout Started446386247620%Opened by an in-app upgrade; the quote is out
In Conversation446386247740%Sales are following up
Subscribed4463862478100%, closed wonThe sale closed
Cancelled44638624820%, closed lostIt 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).


PlantUML diagram

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.

HubSpotArdaWritten by
Subscription.ardacloud_tenant_id (not unique)Tenant.eIdThe 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.eIdThe BFF, on every checkout deal it opens
Contact.ardacloud_user_id (unique)UserAccount.eIdThe BFF (while blank); a one-off script for existing contacts (see Existing Subscriptions)
Subscription.hs_statusTenant.planHubSpot 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:

  1. Find the tenant’s subscriptions: every subscription whose ardacloud_tenant_id is 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 to Personal.
  2. Plan: Paid if any of them has status active, unpaid, past_due or scheduled, whatever product it’s for; otherwise Personal (DQ-005, DQ-006). canceled and expired don’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:

SubscriptionResult
active Arda SoloPaid
active Arda StandardPaid
active Team per-cardPaid
unpaid “Arda Pro - 2 Power Users”Paid
scheduled Arda ProPaid

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:tenant claim, 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 on plan yet. Company settings sends the current plan back 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 plan and takes only the name, company and settings, so a user can’t make their own tenant paid, whatever they send. The webhook writes plan under the HubSpot service key, a service identity of its own in accounts (beside ArdaApiKey and the Cognito post-confirmation key). That key may read a tenant, and change its plan and nothing else.

PlantUML diagram

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’s custom: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 subscription scheduled to 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 stamp ardacloud_user_id with the user account’s eId only 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_id set (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_language set, 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 is https://‹domain›/‹slug›. With paymentType NONE, 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.

PlantUML diagram

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_key header (the dash spelling is accepted too), compared in constant time with ARDA_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_id or properties.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 200 and linked: 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.

  1. 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.
  2. Nothing else. A subscription with no checkout deal (sold by sales, or before this design), or one that has ended (canceled, expired), is answered linked: false and 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.
  3. 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.

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:

  1. stamps ardacloud_user_id on that contact;
  2. 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.


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.

  1. Give existing contacts their user ID. A one-off script stamps ardacloud_user_id on each contact whose arda_oidc_sub matches exactly one Arda user account, with that account’s eId. That’s the only use of the subject; afterwards the user ID is all the BFF reads.
  2. 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, scheduled while Stripe bills them; it counts as paying.
  3. 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.
  4. Backfill. For every paying subscription with no tenant ID, the script decides the tenant from the evidence: the ardacloud_tenant_id sales 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 as Paid. 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 writes ardacloud_tenant_id on each subscription. It skips subscriptions already linked, so it can be run again at any time.
  5. 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 plan matches the plan computed from the subscriptions carrying its ID (Personal when nothing pays). It lists every exception and writes nothing. Starting from Arda is what catches a tenant still Paid after 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.
  6. 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.


Both endpoints are switched on by one config, built only when every value is present. Without it, both answer 503.

Environment variableSourceprodsandbox partitions
HUBSPOT_SUBSCRIPTION_PIPELINE_IDPlain value26333160462633316046
HUBSPOT_SUBSCRIPTION_CHECKOUT_STAGE_IDPlain value44638624764463862476
HUBSPOT_SUBSCRIPTION_ACCESS_TOKENVault credentialproduction service keysandbox service key
HUBSPOT_SUBSCRIPTION_QUOTE_TEMPLATE_IDVault quoteTemplateId830399227610876811118281
HUBSPOT_SUBSCRIPTION_QUOTE_DOMAINVault quoteDomainhelp.arda.cards247746885.hs-sites-na2.com
HUBSPOT_SUBSCRIPTION_PAYMENT_TYPEVault paymentTypeBYO_STRIPENONE
ARDA_SIGNUP_SECRET_KEYExisting partition secretsharedshared
HubSpot service key (name pending)New partition secret, issued by accountsper partitionper 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 / .write
    • crm.objects.deals.read / .write
    • crm.objects.line_items.read / .write
    • crm.objects.quotes.read / .write
    • crm.objects.products.read
    • crm.objects.subscriptions.read, plus the subscriptions write scope, to stamp the tenant ID

  • Unit tests, with HubSpot and ARDA stubbed:
    • the client: property names on the wire, unpaid counted 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.
  • 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 to Personal. 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.
  • 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_id on 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 Paid tenant.
  • Tier changes for paying tenants go through sales (409 in-app) until proration is designed.


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