HighLevel SaaS V1 vs V2 in 2026: The Architecture That Survives Custom Provisioning
Why did checkout succeed but the client account never appear?
Your buyer sees a successful card screen at 11:48 PM. Stripe shows a new subscription. HighLevel shows a contact. The welcome email fires. There is just one problem. The sub-account does not exist.
By 8:10 the next morning, the buyer has replied twice. Your sales rep says the deal is closed. Your support person is asking whether they should create the location manually. Someone opens Stripe, someone else opens Agency View, and a third person reruns the onboarding workflow without knowing that the first run is still waiting on a delayed event.
Now you do not have one failure. You have three possible accounts, two welcome sequences, one paid subscription, and no trustworthy answer to the simplest question in the system: what state is this customer actually in?
This is the part that glossy SaaS Mode walkthroughs skip. The checkout page is not the product. It is the first request in a distributed workflow that crosses a payment provider, HighLevel, your automation layer, email, phone services, snapshots, permissions, rebilling settings, and whatever custom software you promised on top.
The failure above is not hypothetical architecture trivia. I have seen the same shape of incident in CRM provisioning, Shopify app installation, voice-agent onboarding, and marketplace account setup. A transaction succeeds in one system, times out in another, then a human retries the whole process. The retry is what causes the expensive damage.
The open loop in this article is simple: how do you let HighLevel handle what it is good at without pretending its SaaS flow is the only state machine you need?
[REAL NUMBER] Insert the median time between a successful checkout and a support escalation for a failed provisioning event, calculated from your own tickets or event logs.
Is SaaS V2 actually an upgrade from SaaS V1?
No. Treating V2 as a normal replacement for V1 is the first design mistake.
HighLevel's current documentation says the two versions use different architectures. In SaaS V1 vs SaaS V2: What's the Difference?, HighLevel explains that V1 uses Stripe as the system of record for products, customers, and subscriptions. V2 moves that commercial record into an agency master sub-account in HighLevel. V1 is tied to Stripe. V2 can use multiple supported payment providers.
That is not a cosmetic change. It changes who decides whether a customer is active, where subscription IDs live, how a location is associated with a payer, which event starts provisioning, and how support staff inspect billing state.
The labels make the confusion worse. V1 sounds old. V2 sounds current. HighLevel itself clarified in its plan label update that the naming change did not itself alter pricing, features, or behavior. It gave names to two plan structures that agencies may already have been using.
You need to think of them like two billing topologies.
Decision area | SaaS V1 | SaaS V2 |
|---|---|---|
Commercial system of record | Stripe | HighLevel agency master sub-account |
Payment provider | Stripe | Multiple supported providers |
Customer association | Stripe customer linked to location | HighLevel contact linked to location |
Product and subscription management | Primarily Stripe | Primarily HighLevel |
Good fit | Stable Stripe-first operation with V1-specific billing behavior | Provider flexibility and deeper CRM workflow control |
Main architecture risk | Stripe and HighLevel drift | HighLevel contact, subscription, and location drift |
The table is useful, but it still hides the real decision. You are not choosing a checkout screen. You are choosing which database your team will trust during an incident.
That decision affects every custom workflow you add later.
Imagine a customer asks to change from monthly to annual billing. In a Stripe-led V1 model, you inspect Stripe's subscription and its billing interval. In V2, HighLevel's current SaaS Configurator guidance says billing interval changes on existing subscriptions are not currently supported in the same way. If your custom portal presents a button that promises an instant monthly-to-annual change, your interface can create a state that the underlying plan architecture cannot represent cleanly.
A version number does not solve that mismatch.
A data model does.
[DIAGRAM] Draw two side-by-side sequence diagrams. In V1, show buyer, hosted checkout, Stripe customer, Stripe subscription, HighLevel location, and onboarding automation. In V2, show buyer, HighLevel checkout, agency master sub-account, contact, subscription, payment provider, location, and onboarding automation. Mark the commercial system of record in each diagram.
What does a broken SaaS signup really cost?
The obvious cost is support time. That is usually the smallest line item.
A failed or duplicated signup creates billing risk, access risk, and trust risk at the same time. The customer may be charged without access. They may receive access without a charge. They may receive the wrong snapshot, the wrong features, or a duplicate location. They may connect a phone number and start spending from a wallet tied to a location that your billing logic later disables.
Then the failure becomes sticky.
Once your team manually repairs an account, the commercial state and the operational state can diverge permanently. The subscription says Plan Pro. The location has Plan Starter permissions. The contact carries a custom field that says tier_pro. The snapshot installation failed halfway through. A workflow sees the custom field and enables premium onboarding. Another workflow sees the missing tag and disables it. Both are behaving exactly as configured.
You cannot fix that with a nicer welcome email.
For an agency owner, the cost often appears in four places.
First, churn. A buyer who pays and cannot log in does not care whether the root cause was a delayed webhook, a scope error, or an inconsistent plan record. They bought certainty. Your first experience delivered ambiguity.
Second, staff load. Support has to ask engineering what happened. Engineering has to inspect HighLevel, the payment provider, n8n or Make, and email logs. Finance may need to refund or reconcile the payment. Sales may promise a workaround before anyone knows whether another workflow is still running.
Third, margin. SaaS Mode looks attractive because software revenue should be less labor-intensive than custom service work. A fragile provisioning flow converts software margin back into support labor.
Fourth, downstream billing leakage. If rebilled products, phone usage, email, AI features, or other metered services attach to the wrong wallet or location state, the first failed signup can keep costing you after the support ticket closes.
A useful incident metric is not just “failed signups.” Track the number of customer lifecycle records that required a human state correction. A signup that succeeds after someone manually adds a tag is still an automation failure. It merely avoided the error queue.
Here is a minimal incident record I keep outside the visual workflow tool:
create table saas_lifecycle_incidents (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
customer_key text not null,
lifecycle_id uuid,
incident_type text not null,
detected_at timestamptz not null default now(),
resolved_at timestamptz,
automatic_recovery boolean not null default false,
systems_involved text[] not null default '{}',
root_cause text,
resolution text,
created_by text not null default 'system'
);This table does not need to replace your support desk. It gives you a cross-system record that is not rewritten when a workflow retries.
[REAL NUMBER] Insert the total staff minutes spent per lifecycle incident, including support, operations, engineering, and finance. Do not use only the time recorded in the ticket.
Why does the obvious HighLevel SaaS setup fail?
The obvious approach is attractive because it looks complete.
You create a plan. You attach a snapshot. You generate a checkout link. You add an onboarding workflow. You test one purchase. The location appears. The email arrives. Done.
That test proves the happy path can work once. It does not prove the system can survive retries, delayed events, duplicate contacts, payment failures, plan changes, account imports, or two staff members editing the same customer.
The mechanism is distributed state.
A single signup may create or update all of these records:
payment customer
payment subscription
HighLevel agency contact
HighLevel SaaS subscription
HighLevel location
location user
snapshot installation state
permission profile
onboarding workflow state
wallet or rebilling state
custom integration tenant
external database tenant
voice or phone configurationThose records are not committed in one database transaction. There is no global rollback that reverses all of them when step seven fails.
The second mechanism is at-least-once event delivery. Payment systems and automation platforms retry. HighLevel workflows can be re-entered. A person can press a button twice. Your HTTP client can time out after the server completed the request but before your client received the response.
That last failure is the one that catches experienced builders.
Suppose your Node service sends a request to create a location. HighLevel accepts it and creates location abc123. The response is lost because your reverse proxy closes the connection. Your service sees a timeout and retries. Unless your application has an idempotency strategy, it sends another create request. You now have two locations with nearly identical names.
The third mechanism is identity ambiguity. Email is convenient, but it is not a safe global primary key. A founder may buy with billing@company.com and expect access for owner@company.com. An agency may already have a contact with the buyer's email from a lead campaign. A V2 subscription may relate to a contact in the agency master sub-account, while your custom application uses its own user ID.
If your workflow matches records by whatever field happens to be available, it will eventually attach a subscription to the wrong operational entity.
The fourth mechanism is hidden coupling. You may use a HighLevel tag to trigger provisioning. Later, a support rep adds that tag manually while fixing an unrelated issue. The same provisioning workflow runs again. The tag looked like state. It was really a command.
I separate those concepts.
A command says, “attempt to provision this lifecycle.” State says, “this lifecycle reached location_ready at a specific time with a specific location ID.” Tags can be useful projections for humans. They should not be the only authoritative event log for a paid product.
[WORKFLOW JSON: HighLevel or n8n trigger flow that receives a paid-subscription event, resolves the internal lifecycle ID, checks the current state, and refuses a second location-creation command. Show the idempotency key and the branch that returns an existing result.]
// [WORKFLOW JSON: HighLevel or n8n trigger flow that receives a paid-subscription event, resolves the internal lifecycle ID, checks the current state, and refuses a second location-creation command. Show the idempotency key and the branch that returns an existing result.]Which system should own the subscription?
Pick one commercial authority before you build anything custom.
For V1, that authority is normally Stripe because HighLevel documents Stripe as the system of record. For V2, the authority is normally the SaaS subscription represented inside the agency master sub-account, with the selected payment provider acting as payment rail rather than the only commercial database.
Your custom database should not compete with that authority. It should record a normalized projection, event history, internal IDs, and operational state.
That distinction matters.
A bad custom database copies fields such as subscription_status, lets internal code edit them directly, and assumes those edits represent reality. A good integration database records where the status came from, when it was observed, which source event produced it, and which operational transitions have completed.
Here is a simplified model:
create type saas_commercial_system as enum ('highlevel_v1_stripe', 'highlevel_v2');
create type saas_lifecycle_state as enum (
'checkout_started',
'payment_pending',
'payment_confirmed',
'location_requested',
'location_ready',
'snapshot_pending',
'configuration_pending',
'active',
'past_due',
'suspension_pending',
'suspended',
'cancellation_pending',
'cancelled',
'manual_review'
);
create table saas_lifecycles (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null,
commercial_system saas_commercial_system not null,
external_customer_id text,
external_subscription_id text,
highlevel_contact_id text,
highlevel_location_id text,
plan_key text not null,
billing_interval text,
commercial_status text,
operational_state saas_lifecycle_state not null,
source_version bigint not null default 0,
last_source_event_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (commercial_system, external_subscription_id)
);Notice that commercial_status and operational_state are separate.
A subscription can be commercially active while the location is still being configured. It can be past due while you intentionally leave access active during a grace period. It can be cancelled at period end while the location remains active until that date. Compressing all of those facts into one active boolean is how contradictory automations start.
You also need a source event table.
create table saas_source_events (
id uuid primary key default gen_random_uuid(),
provider text not null,
external_event_id text not null,
event_type text not null,
occurred_at timestamptz,
received_at timestamptz not null default now(),
payload jsonb not null,
signature_valid boolean,
processing_status text not null default 'received',
lifecycle_id uuid references saas_lifecycles(id),
error_code text,
error_message text,
unique (provider, external_event_id)
);If a source does not supply a stable event ID, derive a careful fingerprint from immutable fields. Do not hash the entire payload blindly. Providers sometimes add fields or reorder arrays on retry, producing a different hash for the same business event.
The custom database becomes your operational memory. It answers questions such as:
Did we already create the location?
Which source event caused this transition?
Was the snapshot applied?
Which plan definition was used at that time?
Did a retry repeat an irreversible action?
Is the customer paid but still not active?It does not answer “should this card be charged?” unless you are deliberately building your own billing platform. Most agencies should not.
[DIAGRAM] Draw a three-layer diagram. Layer one is the commercial authority, Stripe for V1 or HighLevel V2. Layer two is the lifecycle ledger in Postgres or Supabase. Layer three is operational projections in HighLevel, email, phone, AI, and custom applications. Mark writes and read-only projections clearly.
What does the full customer lifecycle need to look like?
Start with transitions, not screens.
A customer lifecycle is a state machine with evidence attached to every transition. It should handle success, delay, retry, cancellation, and repair. You need enough states to prevent unsafe actions, but not so many that nobody understands them.
A practical flow looks like this:
checkout_started
-> payment_pending
-> payment_confirmed
-> location_requested
-> location_ready
-> snapshot_pending
-> configuration_pending
-> activeFailure does not always mean failed. That label is too vague. Use a state that tells you what can safely happen next.
For example:
payment_confirmed + no location ID
-> safe to retry location lookup
-> safe to retry create only after lookup and idempotency check
location_ready + snapshot unknown
-> unsafe to create another location
-> safe to inspect or retry snapshot installation
active + payment past due
-> enter grace policy
-> do not delete dataThe lifecycle should also carry versioned plan intent. Do not assume the current plan configuration is what the customer bought six months ago.
create table saas_plan_versions (
id uuid primary key default gen_random_uuid(),
plan_key text not null,
version integer not null,
commercial_system saas_commercial_system not null,
external_plan_id text not null,
snapshot_id text,
permission_profile jsonb not null,
included_products jsonb not null,
onboarding_version text not null,
effective_from timestamptz not null,
retired_at timestamptz,
unique (plan_key, version)
);When a buyer checks out, record the plan version used. If you change permissions next week, you can decide whether existing customers inherit the change or remain on the version they purchased. Without versioning, your nightly reconciler may “fix” older customers into a new entitlement set without anyone approving the change.
The command flow should be explicit too.
type LifecycleCommand =
| { type: 'CONFIRM_PAYMENT'; eventId: string }
| { type: 'REQUEST_LOCATION' }
| { type: 'CONFIRM_LOCATION'; locationId: string }
| { type: 'APPLY_SNAPSHOT'; snapshotId: string }
| { type: 'ACTIVATE' }
| { type: 'MARK_PAST_DUE'; sourceStatus: string }
| { type: 'SUSPEND'; reason: string }
| { type: 'CANCEL'; effectiveAt: string };Each command should be validated against the current state. REQUEST_LOCATION from payment_confirmed is allowed. The same command from active should return the existing location result, not create another one. ACTIVATE should require the evidence your product needs, perhaps a location ID, a user, a snapshot result, and a completed permission projection.
Do not make every optional setup step block activation. That creates another failure mode. Decide what is required for the customer to receive the product they paid for, and what can finish asynchronously.
For one agency, phone-number provisioning may be optional. For another, the phone system is the product. Your state machine must reflect the commercial promise, not a generic template.
When is SaaS V1 still the right choice?
V1 can still be the right choice when Stripe is intentionally your commercial center and the behavior you depend on is already stable.
HighLevel's current comparison says V1 is Stripe-only and that Stripe manages the products, customers, and subscriptions. That can be a benefit, not a limitation, when your finance team already operates in Stripe, your tax and reporting processes are built around it, and your custom systems consume Stripe events reliably.
The strongest V1 architecture keeps Stripe authoritative and treats HighLevel as the provisioned application.
Stripe Checkout
-> Stripe customer and subscription
-> signed Stripe event receiver
-> lifecycle ledger
-> HighLevel V1 provisioning or native SaaS action
-> location configuration
-> activationThe important part is not forcing every step through a long Stripe webhook request. Acknowledge the event after you validate and persist it, then process provisioning asynchronously.
A concrete example is a mature agency with annual and monthly Stripe prices, existing coupon behavior, finance reports keyed by Stripe subscription IDs, and an internal cancellation workflow. Moving that agency to V2 only because “V2 is newer” could remove behavior it currently relies on. HighLevel's July 2026 configurator guidance still calls out V1-specific advantages around established Stripe billing behavior, including capabilities that are not yet equivalent in V2.
In that situation, I would not migrate the billing core during a growth campaign. I would first make the V1 lifecycle observable and idempotent. Then I would evaluate whether V2 solves a real requirement, such as supporting a non-Stripe payment provider or moving more subscription operations into HighLevel.
V1 has its own risks.
The Stripe customer and the HighLevel location can drift. A support rep can cancel or modify one side. A location can be created manually without the expected metadata. A subscription can remain active after a location is deleted. The fact that Stripe is a strong billing system does not keep HighLevel projections correct automatically.
I use stable metadata on the Stripe objects when the implementation allows it:
{
"metadata": {
"agency_key": "agency_internal_uuid",
"saas_lifecycle_id": "lifecycle_internal_uuid",
"highlevel_location_id": "location_id_after_creation",
"plan_key": "pro",
"plan_version": "3"
}
}Do not put secrets or sensitive customer data in metadata. Use it as a pointer system.
The V1 model also needs reconciliation. A daily job should compare active Stripe subscriptions with expected HighLevel locations and operational states. It should report mismatches before a customer does.
select
l.id,
l.external_subscription_id,
l.highlevel_location_id,
l.commercial_status,
l.operational_state
from saas_lifecycles l
where l.commercial_system = 'highlevel_v1_stripe'
and (
(l.commercial_status = 'active' and l.operational_state <> 'active')
or (l.commercial_status in ('cancelled', 'unpaid') and l.operational_state = 'active')
or l.highlevel_location_id is null
);This query is not the reconciler. It is the question the reconciler needs to answer with fresh source data.
When does SaaS V2 make more sense?
V2 makes sense when you want the HighLevel agency master sub-account to own the SaaS customer and subscription relationship, especially when provider flexibility or CRM-driven lifecycle automation matters.
HighLevel's current configurator documentation says V2 supports multiple payment providers and can run alongside V1. Its V1 versus V2 comparison describes V2 products, customers, and subscriptions as managed inside HighLevel, with a contact in the agency master sub-account linked to the location.
That creates useful automation options.
You can build CRM workflows around the subscription contact. You can attach sales, onboarding, and account-management activity to a single HighLevel record. You can support providers outside Stripe when HighLevel's provider integration fits your market. You can make more of the commercial lifecycle visible to an operations team that lives in HighLevel all day.
But V2 moves your failure surface too.
The agency contact now matters. Duplicate contacts matter. Merge behavior matters. Permissions around the master sub-account matter. A workflow that edits or deletes the wrong contact can affect the subscription relationship. A support rep who searches only the client location may miss the billing record in the master account.
A concrete V2 example is an agency selling software into a market where Stripe is not the only required payment option. The agency wants subscription sales, contact records, workflows, and client-account creation to remain inside HighLevel. V2 is the logical base. I would still put a lifecycle ledger beside it because the native system should not be the only record of asynchronous provisioning attempts and custom application setup.
The V2 flow should resolve a canonical association:
agency
-> master sub-account
-> billing contact
-> SaaS V2 subscription
-> provisioned location
-> location users
-> custom tenantStore every external ID. Do not keep rediscovering the relationship by email.
create table saas_identity_links (
id uuid primary key default gen_random_uuid(),
lifecycle_id uuid not null references saas_lifecycles(id),
identity_type text not null,
external_id text not null,
external_parent_id text,
verified_at timestamptz,
metadata jsonb not null default '{}',
unique (identity_type, external_id)
);Examples of identity_type are highlevel_agency_contact, highlevel_location, payment_customer, payment_subscription, application_tenant, and primary_user.
The current V2 feature gaps also have to shape your product interface. Do not expose an upgrade or payment-management path because it exists in your React component library. Expose it only when the selected SaaS version and provider support the operation safely.
That means feature flags should be derived from plan architecture:
interface BillingCapabilities {
canChangePlanWithinInterval: boolean;
canChangeBillingInterval: boolean;
supportsProration: boolean;
supportsCouponDeflection: boolean;
supportsSelfServeReactivation: boolean;
supportsSelfServePaymentMethodUpdate: boolean;
}Build the interface from these capabilities. Do not hide unsupported behavior behind a support ticket after the customer clicks it.
Can SaaS V1 and V2 run side by side safely?
Yes, but only when you make the boundary visible.
HighLevel says V1 and V2 can run side by side. It also documents an expected behavior where agencies using both systems may not see every plan while creating a subscription, because the interface shows plans for the current SaaS context. The support article Why can't I see all my SaaS plans while creating a subscription? specifically warns against mixing V1 and V2 for the same workflow.
That is the operational clue.
Running both versions is not dangerous by itself. Ambiguous routing is dangerous.
Every plan, checkout, lifecycle, support action, and reconciliation job needs an explicit commercial_system value. Never infer it from the plan name alone. A staff member can rename a plan. A migration can create similar names. An old screenshot can circulate internally.
I use a plan registry:
plans:
starter-us-monthly-v1:
commercial_system: highlevel_v1_stripe
external_plan_id: price_xxx
billing_interval: monthly
region: US
lifecycle_version: 4
pro-us-monthly-v2:
commercial_system: highlevel_v2
external_plan_id: ghl_plan_xxx
billing_interval: monthly
region: US
lifecycle_version: 6Then every inbound event resolves through the registry before it can issue a command.
The support interface should display V1 or V2 beside every customer. The manual “create subscription” procedure should start with version selection. The incident runbook should have different inspection steps for each system of record.
A side-by-side architecture also needs separate webhook namespaces or event-source identifiers.
/webhooks/stripe/v1/saas
/webhooks/highlevel/v2/saas
/webhooks/highlevel/location-eventsYou can route those paths to the same ingress service, but do not flatten the source before persistence. The event provenance is part of the evidence.
One common mistake is to let V1 and V2 onboarding flows share a trigger tag such as saas-customer. That tag carries no architecture information. If a contact exists in both the agency master sub-account and a client location, the same tag name can mean different things in different scopes.
Use versioned commands instead:
{
"command": "PROVISION_SAAS_LOCATION",
"commercial_system": "highlevel_v2",
"lifecycle_id": "internal_uuid",
"plan_key": "pro-us-monthly-v2",
"command_id": "unique_command_uuid"
}The most important side-by-side rule is this: one customer lifecycle belongs to one commercial system at a time. A migration is a deliberate transition with a mapping record, not a support rep changing which plan screen they use.
How do you provision a HighLevel sub-account exactly once?
You cannot guarantee that the create request is sent once. Networks, workers, and humans retry. You can guarantee that one lifecycle receives one accepted location identity.
That is a different claim, and it is achievable.
The pattern has four parts: a durable command, a unique lifecycle key, lookup before create, and a committed result.
First, insert a command with a unique constraint.
create table saas_commands (
id uuid primary key,
lifecycle_id uuid not null references saas_lifecycles(id),
command_type text not null,
idempotency_key text not null unique,
status text not null default 'pending',
attempt_count integer not null default 0,
locked_at timestamptz,
completed_at timestamptz,
result jsonb,
last_error jsonb,
created_at timestamptz not null default now()
);A useful idempotency key might be:
saas:{lifecycle_id}:create_location:v1The version suffix changes only when the business meaning changes, not on every retry.
Second, lock the lifecycle before deciding what to do.
begin;
select id, operational_state, highlevel_location_id
from saas_lifecycles
where id = $1
for update;
-- Application code evaluates the current state here.
commit;If highlevel_location_id already exists, return it. Do not issue another create request.
Third, search for evidence of a previously created location before retrying an uncertain request. The exact lookup options depend on the HighLevel APIs and metadata available in your account. Use a deterministic marker in the location name only as a last resort. Prefer an external identifier, custom field, or stored response ID that cannot collide.
Fourth, persist the returned location ID before starting snapshot installation or onboarding. Creation and configuration are separate stages. If configuration fails, you retry configuration against the same location.
The worker flow looks like this:
async function provisionLocation(commandId: string): Promise<void> {
const command = await claimCommand(commandId);
if (!command) return;
const lifecycle = await getLifecycleForUpdate(command.lifecycleId);
if (lifecycle.highlevelLocationId) {
await completeCommand(command.id, {
locationId: lifecycle.highlevelLocationId,
reused: true,
});
return;
}
const existing = await findExistingLocationForLifecycle(lifecycle.id);
if (existing) {
await attachLocationAndComplete(command, existing.id, 'recovered');
return;
}
const created = await createHighLevelLocation({
companyName: lifecycle.companyName,
planKey: lifecycle.planKey,
lifecycleId: lifecycle.id,
});
await attachLocationAndComplete(command, created.id, 'created');
}The dangerous window is between HighLevel creating the location and your database committing the returned ID. If the process dies there, the next attempt must search for the created location using the lifecycle marker.
Do not solve that window by marking the command complete before the API call. Then you risk losing a location that was never created.
Do not solve it by holding a database transaction open across the external API request either. That creates long locks and turns a slow vendor response into database contention.
Use an intermediate command state such as requesting, record an attempt ID, call the vendor, then commit the result. Recovery logic handles attempts that remain requesting beyond a threshold.
update saas_commands
set status = 'requesting',
locked_at = now(),
attempt_count = attempt_count + 1
where id = $1
and status in ('pending', 'retryable');For truly critical provisioning, add an outbox record in the same transaction that changes the lifecycle state. A dispatcher reads the outbox and performs the external call. That keeps command creation atomic with your internal transition.
[DIAGRAM] Draw the exact-once-effect flow: payment event, source-event table, lifecycle row, command table, worker claim, HighLevel lookup, conditional create, location ID commit, and configuration commands. Highlight the uncertain network window after the create call.
Where should n8n stop and custom code begin?
I use n8n for visible orchestration. I do not use it as the only transaction ledger for paid account provisioning.
That boundary is opinionated because I have had to repair workflows where execution history was treated as state. It is not. Execution history tells you what one run attempted. It does not give you a normalized, queryable, versioned customer lifecycle across retries and tools.
n8n is a good fit for:
sending internal alerts
coordinating email and Slack notifications
calling well-bounded APIs
running onboarding branches after a committed state transition
human approval steps
scheduled reconciliation orchestration
support-triggered repair commandsCustom Node.js or Next.js code is a better fit for:
signature verification on raw request bodies
idempotent event ingestion
row locking and transactional state transitions
command claiming
rate-aware vendor clients
OAuth token management
exact identity mapping
replay authorization
multi-tenant isolationSupabase can provide Postgres, row-level security, and a quick internal admin surface. Plain Postgres works just as well. The important part is not the vendor. It is having a durable relational model outside the automation canvas.
A clean division looks like this:
Vendor webhook
-> Node or Next.js ingress
-> Postgres source event
-> command dispatcher
-> n8n orchestration webhook
-> secondary integrations
-> command result callbackn8n should receive a compact, internal command envelope rather than the raw vendor payload whenever possible.
{
"command_id": "2e4c7fa0-0000-0000-0000-000000000000",
"lifecycle_id": "6a53c710-0000-0000-0000-000000000000",
"command": "RUN_POST_PROVISIONING_ONBOARDING",
"plan_key": "pro-us-monthly-v2",
"location_id": "highlevel_location_id",
"onboarding_version": "v6",
"correlation_id": "source_event_uuid"
}The workflow can fetch the data it needs using a scoped internal API. That prevents a vendor payload change from breaking twenty nodes downstream.
Make and Zapier can play the same orchestration role for smaller implementations, but I am stricter about irreversible steps. If a workflow can create an account, charge money, delete access, or allocate a phone number, I want the command recorded and guarded by code before the visual tool receives it.
One concrete example is snapshot onboarding. The custom service records that location creation succeeded and inserts APPLY_SNAPSHOT. n8n calls the relevant API or coordinates the native HighLevel action. When it finishes, it posts a signed result to the service. The service checks that the command is still current, records the evidence, and advances the lifecycle.
If n8n retries the callback, the command result endpoint returns the already committed result. If someone manually reruns the workflow, it does not apply a second plan version unless a new command authorizes it.
app.post('/internal/commands/:id/complete', verifyInternalSignature, async (req, res) => {
const result = await completeCommandIdempotently({
commandId: req.params.id,
outcome: req.body.outcome,
evidence: req.body.evidence,
});
res.status(200).json(result);
});This is more work than drawing one long n8n workflow. It is also much easier to operate when a buyer is waiting.
How do snapshots, permissions, and onboarding stay in sync?
Treat configuration as a versioned projection, not as a one-time side effect.
A snapshot can install workflows, funnels, calendars, custom values, and other account assets. It does not automatically prove that every asset is enabled, every permission is correct, every external credential exists, or every custom application has the same plan state.
The plan definition should describe expected configuration.
{
"plan_key": "pro",
"version": 6,
"snapshot_id": "snapshot_external_id",
"permissions": {
"contacts": true,
"workflows": true,
"saas_reporting": false,
"custom_menu_voice_ai": true
},
"features": {
"retell_agent_limit": 3,
"custom_dashboard": true,
"priority_support": false
},
"required_setup": [
"primary_user",
"snapshot_applied",
"timezone_confirmed",
"business_profile_confirmed"
]
}Do not invent feature limits in a public article or hard-code them from this example. Replace them with your real plan definition.
The onboarding process should produce evidence for each required condition. A single onboarding_complete tag is not enough.
create table saas_entitlement_projections (
lifecycle_id uuid not null references saas_lifecycles(id),
plan_version_id uuid not null references saas_plan_versions(id),
projection_type text not null,
target_system text not null,
desired_state jsonb not null,
observed_state jsonb,
status text not null default 'pending',
last_checked_at timestamptz,
last_applied_at timestamptz,
primary key (lifecycle_id, projection_type, target_system)
);A reconciler can compare desired and observed state. It should not automatically overwrite every difference. Some differences are legitimate manual customization. Classify fields as managed, advisory, or customer-owned.
For example, a plan may manage whether a custom menu link is available. The customer's calendar configuration is customer-owned. Your reconciler can alert on missing required calendars without recreating them blindly.
Permissions are especially sensitive because they can fail open or fail closed.
Failing open means a lower-tier customer receives a premium feature. That costs margin and can create a contractual issue. Failing closed means a paying customer loses a feature. That creates support and churn.
I prefer a safe activation gate. A location can exist while configuration is pending, but the buyer is not told the full product is ready until required projections are verified.
At the same time, do not wait for every optional integration. Send an honest status page:
Account created
Core workspace ready
Phone connection waiting for customer action
AI agent setup scheduledThat is better than hiding all access until a customer supplies a phone credential they may not provide for days.
[WORKFLOW JSON: n8n configuration workflow that accepts a lifecycle command, installs or verifies the snapshot, applies managed permissions, writes evidence for each step, and returns a signed completion callback. Include the branch for an existing snapshot and the branch for a partial prior run.]
// [WORKFLOW JSON: n8n configuration workflow that accepts a lifecycle command, installs or verifies the snapshot, applies managed permissions, writes evidence for each step, and returns a signed completion callback. Include the branch for an existing snapshot and the branch for a partial prior run.]What breaks during upgrades and downgrades?
The button is easy. Entitlement transition is hard.
HighLevel's current documentation says V2 does not support every billing transition that users may expect from a mature Stripe subscription flow. The SaaS Configurator guide calls out limits around billing interval changes, prorations, coupon deflections, self-service reactivation, and payment-method updates. The exact supported set can change, so your implementation should link to and test the current behavior before presenting it to buyers.
The architecture problem exists even when the billing operation succeeds.
An upgrade may need to enable features immediately. A downgrade may need to wait until period end. A cancellation may preserve read access for export. A plan change may require a new snapshot, but installing a new snapshot could overwrite customer configuration.
Represent the transition as its own object.
create table saas_plan_transitions (
id uuid primary key default gen_random_uuid(),
lifecycle_id uuid not null references saas_lifecycles(id),
from_plan_version_id uuid not null references saas_plan_versions(id),
to_plan_version_id uuid not null references saas_plan_versions(id),
requested_at timestamptz not null default now(),
effective_at timestamptz,
commercial_transition_id text,
commercial_status text not null default 'pending',
entitlement_status text not null default 'pending',
rollback_status text,
reason text,
unique (lifecycle_id, commercial_transition_id)
);Separate the commercial transition from the entitlement transition. The payment system may confirm an upgrade before all custom features are enabled. Your support screen needs to show that difference.
A concrete example is a customer upgrading from a basic plan to a plan that includes a Retell AI voice agent. Billing can update in seconds. The voice setup needs a phone number, agent configuration, knowledge source, transfer rules, and customer approval. Marking the whole lifecycle “upgraded” hides the unfinished work.
Use a transition checklist:
commercial change confirmed
new desired entitlements computed
safe additive permissions applied
customer action requests created
external resources provisioned
verification completed
old entitlements retired when allowed
transition closedDowngrades need more caution than upgrades. Removing a feature can delete or orphan customer data. I avoid automatic destructive downgrades unless the data-retention behavior is explicit and tested.
For a downgrade, classify every entitlement:
Entitlement type | Example | Typical downgrade action |
|---|---|---|
Access flag | Custom menu item | Hide after effective date |
Capacity | Number of agents | Block new creation, preserve existing until reviewed |
Metered service | Phone or AI usage | Change rate or stop new usage according to contract |
Stored data | Recordings or reports | Retain, export, archive, or delete by policy |
Workflow asset | Premium automation | Disable safely, do not delete blindly |
Your public plan page may say “upgrade anytime.” It should not imply that every unsupported billing interval change or downgrade can happen without human review.
How should rebilling and the client wallet be modeled?
Rebilling is not just another subscription feature. It is a second money flow attached to a provisioned account.
HighLevel's SaaS V2 rebilling documentation describes billing clients through the agency sub-account and using the sub-account wallet for supported products and services. HighLevel also published unified payment options across SaaS V1 and V2, which aligns wallet and card choices across locations. You still need to model who is allowed to spend, which location incurs the charge, and how your support team reconciles usage.
The wallet is operational state with financial consequences.
A location can be active while the wallet is empty. A card can fail while the core subscription remains active. A client user may have permission to add funds. An agency admin may cover usage temporarily. Your automation must not treat “subscription active” as “all metered services funded.”
I keep a normalized usage and rebilling projection even when HighLevel performs the actual charge.
create table saas_metered_usage_events (
id uuid primary key default gen_random_uuid(),
lifecycle_id uuid not null references saas_lifecycles(id),
provider text not null,
external_event_id text not null,
product_key text not null,
quantity numeric not null,
unit text not null,
occurred_at timestamptz not null,
billing_status text not null default 'observed',
source_payload jsonb not null,
unique (provider, external_event_id)
);This does not mean you calculate the invoice independently. It lets you answer whether usage occurred, which lifecycle it belongs to, and whether the expected billing projection saw it.
A concrete failure is a custom voice-agent service connected to the wrong location ID. Calls succeed. Usage accumulates. The customer who owns the agent is not the location whose wallet is charged. By the time finance notices, call logs, contact records, and wallet transactions point in different directions.
Prevent that with immutable tenant context in every command.
{
"agency_id": "internal_agency_uuid",
"lifecycle_id": "internal_lifecycle_uuid",
"highlevel_location_id": "target_location_id",
"product_key": "voice_ai_calls",
"resource_id": "retell_agent_id",
"correlation_id": "call_or_event_id"
}Never let the downstream workflow pick a location from an email search when the command already knows the canonical location ID.
Rebilling also needs guardrails around suspension. If a wallet issue occurs, decide which service stops. Do not disable the whole CRM because one metered add-on cannot collect payment unless that is the documented contract.
[REAL NUMBER] Insert the percentage of lifecycle incidents caused by incorrect location, wallet, or resource association, calculated from your own incident records.
What happens when payment fails, cancels, or comes back?
A payment failure is not a delete command.
Your commercial source may report past due, unpaid, cancelled immediately, or cancelled at period end. Those statuses do not map cleanly to one operational action. You need policy.
A practical policy engine considers:
commercial status
failure reason
attempt count if available
current billing period end
grace period
customer segment
data-retention contract
metered-service exposure
manual overrideThe policy returns commands, not direct side effects.
interface AccessDecision {
desiredState: 'active' | 'grace' | 'restricted' | 'suspended' | 'cancelled';
effectiveAt: string;
commands: Array<
| 'NOTIFY_CUSTOMER'
| 'NOTIFY_ACCOUNT_OWNER'
| 'PAUSE_METERED_SERVICES'
| 'RESTRICT_PREMIUM_FEATURES'
| 'SUSPEND_LOCATION'
| 'SCHEDULE_EXPORT'
>;
reasonCode: string;
}Do not let a single webhook disable a customer location before you verify the event is current. Events can arrive out of order. A failed-payment event may be followed quickly by a successful retry, and processing order across systems is not guaranteed.
Record source version or event time, then fetch the current subscription before a destructive action when the source supports it. The webhook wakes your system. The source read confirms current truth.
Cancellation at period end needs a scheduled command tied to the effective date. If the customer reactivates before then, cancel the suspension command. Do not leave a hidden delayed n8n execution that will disable the account anyway.
Use a schedulable command table:
alter table saas_commands
add column not_before timestamptz,
add column cancelled_at timestamptz;
create index saas_commands_ready_idx
on saas_commands (status, not_before)
where status = 'pending' and cancelled_at is null;The dispatcher claims commands whose not_before has passed. Reactivation sets cancelled_at on the pending suspension command and inserts a new reconciliation command.
A concrete example is a customer whose card fails, then succeeds after they update it. If your first workflow schedules a 72-hour suspension inside the automation platform and the success workflow only removes a HighLevel tag, the delayed run may still fire. The visible state looks repaired until the timer expires.
Persistent scheduled commands make that future action inspectable and cancelable.
Manual overrides need an expiry too. Support should not set do_not_suspend = true forever. Store who approved the override, why, and when it expires.
create table saas_policy_overrides (
id uuid primary key default gen_random_uuid(),
lifecycle_id uuid not null references saas_lifecycles(id),
override_type text not null,
reason text not null,
approved_by text not null,
starts_at timestamptz not null default now(),
expires_at timestamptz,
revoked_at timestamptz
);Cancellation is where data ownership becomes real. Decide whether you retain the location, archive it, export data, remove custom integrations, release phone numbers, revoke OAuth tokens, and delete external tenants. Those actions have different reversibility. Put the irreversible ones last and require evidence that the retention period has ended.
How do you test the architecture before a real buyer finds the gap?
Do not test only with a fresh email, a valid card, and a fast connection.
Your test plan should attack the boundaries between systems. The goal is not to prove that each API works. It is to prove that repeated and reordered events produce one correct customer lifecycle.
I build a replay fixture from sanitized source events.
{
"fixture": "v2-payment-confirmed-location-timeout",
"initial_state": {
"commercial_status": "pending",
"operational_state": "payment_pending",
"highlevel_location_id": null
},
"events": [
{
"sequence": 1,
"type": "subscription.payment_confirmed",
"external_event_id": "evt_fixture_001"
},
{
"sequence": 2,
"type": "subscription.payment_confirmed",
"external_event_id": "evt_fixture_001",
"duplicate": true
}
],
"faults": [
{
"operation": "create_location",
"behavior": "response_timeout_after_remote_success"
}
],
"expected": {
"location_count": 1,
"final_state": "configuration_pending",
"manual_review": false
}
}The most valuable cases are uncomfortable:
the payment event arrives twice
the cancellation event arrives before the activation worker finishes
the create-location response times out after remote success
the contact already exists with a different company name
the plan is renamed after checkout
the snapshot applies partially
the welcome email fails after activation
the same buyer opens two checkout tabs
a support rep manually creates a location during retry
the payment provider succeeds but the HighLevel event is delayed
the database commits, then the n8n trigger is unavailable
the n8n callback repeats after the command already completedTest authorization too. A command for Agency A must never read or mutate Agency B, even if an external ID collides or a webhook path is guessed.
For Supabase, use row-level security or a private service role behind your server. Do not expose lifecycle mutation tables directly to a browser.
alter table saas_lifecycles enable row level security;
create policy lifecycle_read_by_agency
on saas_lifecycles
for select
using (agency_id = current_setting('app.agency_id')::uuid);The exact policy depends on your authentication design. Do not copy this fragment without setting the session context securely.
Your load test should measure queue age, not just request throughput. A system can return 200 quickly while provisioning is falling hours behind.
Track:
source event age
command queue age
provisioning duration by stage
retry count
manual review count
commercial-active but operational-inactive count
location-without-subscription count
subscription-without-location count[REAL NUMBER] Insert the 95th percentile end-to-end provisioning time from payment confirmation to required activation evidence in your staging or production environment.
What changes under load or across hundreds of locations?
The architecture changes from request handling to queue management.
At low volume, a delayed onboarding feels like one customer issue. At higher volume, the same delay becomes a backlog. If the worker retries every failure immediately, it can make the vendor limit worse. If one agency has a bad configuration, it can consume every worker slot and delay healthy tenants.
You need bounded concurrency per external system and, ideally, per agency.
interface QueuePolicy {
globalConcurrency: number;
perAgencyConcurrency: number;
maxAttempts: number;
retryScheduleSeconds: number[];
circuitBreakerThreshold: number;
}Do not copy arbitrary values. Tune them against current HighLevel API limits, provider limits, and your own database capacity. HighLevel's API documentation points developers to current authentication and limit guidance. Limits can change. Read them during implementation.
Use exponential backoff with jitter for retryable errors. Respect Retry-After when the provider sends it. Do not retry validation errors until the underlying data changes.
Classify errors:
Error class | Example | Action |
|---|---|---|
Retryable transport | Timeout, connection reset | Retry with lookup and backoff |
Provider throttling | HTTP 429 | Respect provider delay, reduce concurrency |
Authentication | Expired or revoked token | Refresh or route to connection repair |
Validation | Missing required business field | Manual or customer action |
Conflict | Existing location or duplicate identity | Resolve canonical record |
Policy | Unsupported interval change | Present supported path, do not retry |
Unknown | New provider response | Stop unsafe action and inspect |
A circuit breaker prevents one failing vendor endpoint from generating a retry storm. While open, persist new commands but do not hammer the endpoint. Probe carefully, then resume.
Multi-tenant fairness matters too. A large agency importing many customers should not block a small agency's first signup. Use queue partitioning or weighted scheduling.
The database needs the right indexes. Command claiming should not scan every completed row.
create index saas_commands_claim_idx
on saas_commands (not_before, created_at)
where status in ('pending', 'retryable')
and cancelled_at is null;Claim with for update skip locked so multiple workers do not process the same command.
with next_command as (
select id
from saas_commands
where status in ('pending', 'retryable')
and cancelled_at is null
and coalesce(not_before, now()) <= now()
order by created_at
for update skip locked
limit 1
)
update saas_commands c
set status = 'processing',
locked_at = now(),
attempt_count = attempt_count + 1
from next_command n
where c.id = n.id
returning c.*;Observability has to be tenant-aware. A global error rate can look fine while one agency is completely broken. Alert on per-agency queue age, repeated authentication failures, and lifecycle mismatches.
Reconciliation also changes under load. Do not fetch every location and subscription on every run. Use incremental events for speed and periodic full or sampled reconciliation for truth. Partition work, cache immutable plan definitions, and keep a checkpoint.
The hardest scaling problem is usually not raw webhook volume. It is the number of partially completed lifecycles that need different repair paths. A fast system that creates partial state faster is worse.
What are the real tradeoffs of this design?
You are adding a small control plane beside HighLevel. That costs engineering time and operational ownership.
You need a database schema, migrations, workers, monitoring, backups, access control, and a repair interface. Your team must understand that the lifecycle ledger is operational authority, while V1 Stripe or V2 HighLevel remains commercial authority. Support needs a runbook. Developers need tests for provider changes.
For a tiny agency selling one plan to a few customers, that may be too much. Native SaaS Mode with careful manual procedures can be reasonable. I would not build a command system to avoid five minutes of monthly support.
The design becomes justified when at least one of these is true:
provisioning creates custom resources outside HighLevel
one failure can charge or disable the wrong customer
multiple SaaS versions or payment providers are active
plan entitlements span HighLevel, voice AI, dashboards, and external apps
support cannot currently explain lifecycle state from one screen
retries have created duplicate accounts or actions
customer count makes manual reconciliation unreliableThere is also a product tradeoff. The more custom lifecycle behavior you add, the less you can assume future HighLevel UI changes will cover your flow. You own the gap.
I still prefer that ownership over hidden coupling. A native-only system can look simpler while its state is spread across contacts, tags, plan screens, Stripe, workflows, and staff memory. The custom ledger makes complexity visible. It does not create all of it.
Another tradeoff is speed of change. HighLevel can add V2 capabilities, alter payment-provider behavior, or change plan interfaces. Your capability matrix and tests need maintenance. Hard-coded assumptions age badly.
Keep vendor-specific adapters thin:
interface SaasCommercialAdapter {
getSubscription(id: string): Promise<CommercialSubscription>;
verifyEvent(rawBody: Buffer, headers: Headers): VerifiedEvent;
normalizeEvent(event: VerifiedEvent): NormalizedCommercialEvent;
requestPlanChange(input: PlanChangeRequest): Promise<PlanChangeResult>;
requestCancellation(input: CancellationRequest): Promise<CancellationResult>;
}V1 Stripe and V2 HighLevel implement different adapters. The lifecycle engine consumes normalized facts and capabilities.
The final tradeoff is false confidence. An internal dashboard can make state look neat while source systems disagree. Your reconciler must keep asking the providers. The ledger is not correct because it is yours.
What would I do differently if I built it again?
I would start with the lifecycle table before building the checkout page.
Earlier in my career, I let the first successful event trigger the next visual workflow and called that architecture. It worked while every step completed. The first retry exposed that I had commands, state, and identity all mixed together.
Now I make seven decisions early.
First, I choose the commercial authority. V1 Stripe or V2 HighLevel. Never both for one lifecycle.
Second, I assign an internal lifecycle ID before irreversible provisioning. Every external object points back to it when possible.
Third, I persist raw verified events before branching. A failed parser should not erase the evidence.
Fourth, I put account creation behind an idempotent command. A retry returns the accepted location, not a second location.
Fifth, I separate commercial status from operational access. Past due is an input to policy, not an automatic delete.
Sixth, I version plan entitlements. A customer bought a defined plan version, not whatever the plan editor contains today.
Seventh, I build reconciliation and repair before launch. A system without repair is not finished. It is only untested.
I would also avoid using contact tags as the only trigger for paid provisioning. Tags are excellent for visibility and workflow routing. They are too easy to add, remove, duplicate, and reuse to serve as the sole transaction record.
I would avoid one giant n8n workflow. Split it into commands with durable outcomes. Smaller workflows are easier to rerun safely and easier to replace when an API changes.
I would keep destructive cancellation steps manual until the retention policy is written. Deleting or disabling too early can turn a billing problem into a data-loss incident.
And I would make the support screen before the executive dashboard. The person on a customer call needs to know:
what the customer bought
which commercial system owns it
whether payment is current
which location belongs to it
which setup stages completed
what failed
what can be retried safely
what must not be repeatedThat screen pays for itself the first time a checkout succeeds and the location does not appear.
What is still the hardest part?
The hardest part is not V1 or V2. It is preserving one customer identity and one lifecycle truth while payments, contacts, locations, workflows, wallets, and humans all change state on different clocks.
That is the open loop from the failed checkout at the start. The right design does not promise that every step succeeds on the first attempt. It makes every partial success visible, every retry safe, and every manual repair accountable.
Even after you build the ledger, the command queue, and the reconciler, one problem remains: deciding which system to trust when two authoritative-looking records disagree and the action is irreversible. That decision still needs evidence, policy, and sometimes a human who understands what the customer was actually promised.