FA©26
08 August 2026·33 min readHighLevelGoHighLeveln8nAPI migrationOAuth

HighLevel V1 API Is End of Support: How I Migrate n8n, Make, and Custom Integrations to V2 Without Breaking Client Automations

Why did a working HighLevel automation suddenly become a business risk?

HighLevel published its V1 end-of-support notice on June 26, 2026. Active V1 integrations were not switched off immediately, but V1 stopped receiving updates, fixes, security patches, and technical support. HighLevel also disabled the creation of new V1 API keys. The official notice is clear about the practical status: the integration may still run, but it is running without a safety net. You can read the notice in HighLevel's V1 to V2 migration announcement.

That distinction matters. A hard shutdown creates a visible deadline. End of support creates a quiet liability. Your workflows keep passing health checks until a field changes, a permission assumption stops holding, a webhook is retried differently, or a client asks you to clone the setup into a new sub-account and you discover you cannot generate the credential your template expects.

The uncomfortable version usually looks like this. An agency has a lead intake workflow in n8n, a Make scenario that updates opportunities, a custom Next.js portal that reads conversations, and a Zapier fallback that creates contacts from a partner form. All four use the same V1 key. Nobody intended that architecture. It grew one emergency fix at a time.

The key has three properties that make the system feel convenient and make the migration dangerous. It is broad, it is static, and it hides the difference between agency and location context. The workflow author does not have to think carefully about permission boundaries because the credential often has enough access to make the request succeed. That convenience becomes technical debt when V2 asks you to name the exact scopes, account type, and installation model.

The first mistake is treating “still working” as “safe to postpone.” The second is treating “deprecated” as a documentation problem. It is an operational problem because every new client, cloned snapshot, credential rotation, and developer handoff increases the chance that the unsupported path becomes the only path available during an incident.

I classify V1 dependencies in three groups before I discuss migration dates with a client. The first group can stop revenue immediately, such as lead capture, booking, payment, and inbound conversation handling. The second group corrupts operations quietly, such as custom field sync, attribution, pipeline movement, and owner assignment. The third group is recoverable, such as nightly reporting or archival exports. This order decides what I dual-run first and what I can migrate later.

The open loop is not whether V2 can create a contact. It can. The harder question is whether your new path preserves the business meaning encoded around that contact. We will come back to that after the transport layer is working.

What does HighLevel V1 end of support actually cost you?

The visible migration work is endpoint replacement. The expensive work is uncertainty.

When an unsupported integration fails, your team loses the normal escalation path. HighLevel states that support can no longer troubleshoot V1 API issues. That means your engineer must first prove whether the failure is in HighLevel, the old endpoint, the automation platform, the credential, the payload, the client configuration, or your own state. A ten-minute API call becomes an incident investigation across several products.

The cost lands in four places.

First, missed work. A contact created without the right tags may never enter the intended workflow. A booking written against the wrong calendar may not notify the assigned rep. An opportunity update that silently fails may leave sales working from stale pipeline data. These are not infrastructure metrics. They are operational omissions, and omissions are harder to detect than hard errors.

Second, duplicated work. During incidents, people retry. n8n retries. Make retries. A webhook provider retries. A support person manually creates the record. Without idempotency, the recovery action causes the next problem. Two contacts trigger two campaigns. Two appointments reserve overlapping capacity. Two invoices or messages create a client-facing mess.

Third, migration pressure. If you wait until a V1 behavior changes, you are forced to migrate under active failure. That removes the option to compare old and new responses, run shadow traffic, or pause after one feature. You are changing authentication, scopes, endpoint contracts, and state handling while the client is watching a broken dashboard.

Fourth, account churn. Agency owners do not usually fire an automation subcontractor because one API call failed. They leave when the subcontractor cannot explain what failed, what data was affected, whether it will happen again, and how the fix is being verified. Observability and incident communication are part of the technical product.

A concrete example is owner assignment. The old workflow receives a form, searches a contact, creates or updates it, then sets an assigned user based on a service area. The V2 contact operation succeeds, but the user lookup returns a different response shape and the mapping node reads the wrong property. The record exists, so the workflow is marked successful. The lead sits unassigned until somebody notices. Your uptime is excellent. Your outcome is wrong.

That is why I do not define migration success as “all endpoints return 2xx.” I define it as contract equivalence at the business boundary. The same normalized input should produce the same intended contact identity, location, assignment, tags, fields, opportunity state, and downstream events. Where V2 intentionally behaves differently, that difference needs an explicit decision.

// [WORKFLOW JSON: n8n comparison workflow that sends one canonical lead payload through the current V1 path and a non-writing V2 contract-test path, then records field-level differences. This proves whether both paths preserve the same business data before cutover.]

[REAL NUMBER] Insert the count of active V1 workflows, distinct V1 endpoints, locations affected, and business-critical paths found during the audit. Do not estimate. Pull the number from the inventory.

There is also a security cost. V1 API keys are broad. V2 introduces scoped authentication so an integration receives only the permissions it needs. Postponing the migration means preserving a credential model with a larger blast radius than necessary. If a shared key appears in an exported n8n workflow, a Make blueprint, a support screenshot, or a copied environment file, the exposure is not limited to one narrow action.

The best commercial reason to migrate is not fear of a shutdown date. It is reducing the number of ways one old secret can affect multiple client systems.

Why does the obvious V1 to V2 migration approach break?

The obvious plan is seductive: create a V2 token, change the base URL, adjust a few field names, and publish the workflow. That works in a demo because the demo has one location, one happy-path payload, one credential, and no traffic arriving during deployment.

Production has none of those properties.

V2 changes the authorization model. HighLevel supports Private Integration Tokens for internal, limited-scope integrations and OAuth 2.0 for installable, multi-account applications. V2 also expects precise scopes. A request can now fail because the token is valid but lacks the permission for the endpoint or webhook event. That is a better security model, but it exposes assumptions your V1 workflows never had to declare.

The tenant model also becomes harder to ignore. HighLevel resources may be scoped to an agency, company, location, user, or installation context. A token that works for one sub-account does not prove your multi-client architecture is correct. If your workflow selects a token from a generic credential and accepts locationId from the incoming payload without verifying the relationship, you have created a cross-tenant data risk.

Response contracts cause the next class of failure. The HTTP request succeeds, but the data returned is not shaped like the V1 node expected. A downstream expression reads body.contact.id while the new client returns contact.id, or pagination moves to a cursor that the loop ignores. The migration passes a single-item test and drops everything after the first page in production.

Webhooks are another trap. Teams often test outbound API calls and assume the migration is complete. HighLevel's current webhook guidance requires signature verification, and the platform has announced that the legacy X-WH-Signature header will be deprecated on September 1, 2026. Integrations should verify X-GHL-Signature with the documented Ed25519 public key. The details are in the HighLevel webhook integration guide. If your webhook receiver parses JSON before preserving the raw body, signature verification can fail because the verified bytes are no longer identical to the signed bytes.

The final mechanism is side effects. A contact upsert is not just a database write. It may trigger a HighLevel workflow, create an opportunity, send a notification, start a campaign, or invoke another webhook. Dual-writing V1 and V2 without isolating those effects can create duplicates even when both API responses are correct.

Here is the pattern I avoid:

const result = await fetch("https://services.leadconnectorhq.com/contacts/", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.GHL_TOKEN}`,
    "Content-Type": "application/json",
    Version: "2021-07-28"
  },
  body: JSON.stringify(incomingWebhookBody)
});

return result.json();

It forwards an untrusted external payload directly into a tenant-scoped write. It has no canonical schema, no idempotency key, no token-to-location check, no timeout policy, no classification for 401 versus 403 versus 429, no response validation, and no record of what business operation was intended.

The safer shape is an adapter boundary. Your workflow maps the incoming event into an internal command, such as UpsertLead, and the HighLevel adapter is responsible for turning that command into the current API request. The rest of your system should not know whether the provider is V1, V2, PIT, OAuth, or a mock used in tests.

type UpsertLeadCommand = {
  tenantId: string;
  externalEventId: string;
  locationId: string;
  identity: {
    email?: string;
    phone?: string;
  };
  profile: {
    firstName?: string;
    lastName?: string;
  };
  customFields: Record<string, string | number | boolean | null>;
  source: string;
};

interface CrmAdapter {
  upsertLead(command: UpsertLeadCommand): Promise<{
    providerRecordId: string;
    created: boolean;
    contractVersion: string;
  }>;
}

That extra layer feels slower on day one. It is faster during every platform change after day one.

[DIAGRAM] Draw the bad path and the safer path side by side. Bad path: form webhook directly into four HighLevel HTTP nodes with a shared V1 key. Safer path: webhook receiver, canonical command, idempotency store, tenant credential resolver, HighLevel V2 adapter, audit log, and downstream event publisher.

Should you use a Private Integration Token or OAuth 2.0?

This decision is architectural, not cosmetic.

HighLevel's authorization guidance says to use a Private Integration Token, usually called a PIT, for internal use where you access one sub-account at a time and do not need the installable app model. Use OAuth 2.0 when you are building a public or multi-account integration, need marketplace installation, webhooks, custom modules, or standardized user authorization. The current decision guidance is in HighLevel's authorization documentation.

A PIT is appropriate for a tightly controlled internal automation. An agency owns the HighLevel account, owns the n8n instance, and wants a workflow for one location or a small set of explicitly managed locations. The token is generated in the UI, scoped, and used as a bearer token. HighLevel describes PITs as fixed tokens. They do not refresh automatically. The platform recommends rotation, and its documented rotation flow provides a seven-day overlap where the old and new token can both work. See the Private Integrations documentation for the current behavior.

OAuth is the correct choice when another agency or location installs your application. Each installation needs its own authorization state. HighLevel access tokens are valid for a day, and the refresh flow returns the credentials you need to continue. The official FAQ describes daily access-token expiry, refresh-token handling, and the recommendation to save both new tokens after refresh. See the HighLevel OAuth FAQ.

I use a simple test. Ask who controls installation and revocation.

If your engineering team manually creates the credential inside an account you operate, PIT may fit. If a customer, agency admin, or location admin must be able to install, consent, revoke, or move the integration without giving you a secret, use OAuth.

Do not choose PIT because OAuth feels like extra backend work. That shortcut becomes a support process where clients send tokens through email, Slack, or password managers, and every permission change becomes a manual ticket. Do not choose OAuth for a one-off internal workflow just to sound enterprise. You will own consent callbacks, token storage, refresh locking, uninstall handling, and installation state even when the business does not need them.

The tenant record in Postgres should make the choice explicit.

create type ghl_auth_mode as enum ('pit', 'oauth');

create table ghl_installations (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null,
  location_id text not null,
  company_id text,
  auth_mode ghl_auth_mode not null,
  access_token_ciphertext text not null,
  refresh_token_ciphertext text,
  access_token_expires_at timestamptz,
  scopes text[] not null default '{}',
  status text not null default 'active',
  installed_at timestamptz not null default now(),
  revoked_at timestamptz,
  unique (tenant_id, location_id)
);

The important field is not the token. It is the binding between your tenant and the HighLevel resource the token is allowed to access. Every job should resolve the installation from your own trusted tenant context. Never accept an arbitrary location ID from a public webhook and use it to select a credential.

For n8n, I prefer a credential service rather than storing a separate OAuth token directly inside every workflow. The workflow sends an internal tenant identifier to a small API. The API resolves and refreshes the HighLevel credential, applies the correct scopes and headers, sends the provider request, and returns a normalized result. This keeps refresh-token mutation out of visual workflow nodes and gives you one place to lock concurrent refresh attempts.

For a narrow PIT workflow, an n8n credential can be reasonable. Even then, name it with the location and purpose, restrict who can edit it, and keep the token out of workflow JSON exports.

The wrong auth choice does not usually fail during development. It fails when the second tenant arrives, when the first token must rotate, or when a client wants to revoke access without breaking unrelated automations.

How do you find every V1 dependency before touching production?

You cannot migrate what you cannot see.

Most V1 estates are larger than the workflow list suggests because credentials and calls hide in places the original builder no longer remembers. A single automation may invoke an n8n sub-workflow, call a Next.js route, enqueue a Supabase Edge Function, or write to a table that another Make scenario polls. Searching only for the HighLevel node misses generic HTTP nodes and custom code.

I start with evidence, not interviews. People remember the workflow they built. They forget the temporary webhook created during a launch.

In n8n, export workflow definitions from the relevant project and search for V1 hostnames, authorization header patterns, credential IDs, version headers, location IDs, and known endpoint fragments. Inspect disabled workflows too. A disabled workflow can be reactivated by an operator during an incident. Search sub-workflows, error workflows, and expressions that build URLs dynamically.

In Make, export blueprints and search HTTP modules, variable stores, connection names, webhook responses, and data structures. Check inactive scenarios and templates copied across teams. In Zapier, review Custom Request actions, Code steps, Webhooks by Zapier, and any private app that wraps the old API.

In code, search environment-variable names as well as URLs. A wrapper may hide the base URL behind GHL_API_URL, and the visible call is only /contacts. Search deployment secrets in the hosting platform, CI configuration, Supabase secrets, container environment files, and serverless function settings. Do not print the values. Record names, owners, and references.

The inventory needs to describe business behavior, not just technical assets. I use columns like these:

Field

Why it matters

Workflow or service

Where the dependency lives

Business operation

Lead create, appointment book, opportunity move, message send, report read

Trigger

Form, webhook, schedule, manual action, queue

V1 endpoint

What must be replaced

Auth source

Shared key, environment variable, platform credential

HighLevel context

Agency, company, or location

Side effects

Workflows, campaigns, notifications, external webhooks

Idempotency behavior

None, provider ID, custom key, database lock

Failure owner

Who sees and resolves the issue

Criticality

Revenue-blocking, silent corruption, recoverable

V2 endpoint and scopes

Migration target

Cutover method

Shadow, dual-read, dual-write, direct switch

A concrete example is a “create contact” workflow that is not actually a create-contact workflow. The form enters n8n, contact identity is normalized, HighLevel is searched, a contact is created or updated, a tag is added, a custom field is set, an opportunity is created, a note is attached, and an internal Slack message is sent. If you inventory only the first API call, you will declare the workflow migrated while later V1 requests remain.

I also instrument the current system before changing it. Add temporary structured logging around every V1 call. Record the operation name, endpoint template, tenant, location, status code, latency, response contract version, and a redacted request fingerprint. Do not log access tokens, full contact payloads, transcripts, or arbitrary request bodies.

logger.info("ghl_v1_call", {
  operation: "contact.upsert",
  tenantId,
  locationId,
  endpointTemplate: "/contacts/",
  statusCode: response.status,
  durationMs,
  requestFingerprint,
  workflowId,
  executionId
});

The temporary logs answer two questions interviews cannot. Which paths are still active, and which payload shapes actually occur. A workflow may have an optional branch for phone-only leads that nobody includes in the test data. Production logs expose it.

// [WORKFLOW JSON: sanitized n8n inventory helper that scans exported workflows for HighLevel V1 URLs, shared credential references, static location IDs, and Version headers. This proves the audit covers generic HTTP nodes and nested expressions, not only native HighLevel nodes.]

[REAL NUMBER] Insert the number of V1 calls observed by operation during a representative business period, separated by production, test, and disabled assets. Mark any observation window that is incomplete.

Do not remove the logging after migration. Change the event name and keep the contract version. The inventory becomes your regression baseline and later your decommission proof.

How do you map endpoints, scopes, tenant context, and response contracts?

Once the inventory exists, I turn it into a migration matrix. This is where most of the thinking happens.

A row is not “contacts endpoint migrated.” A row is one business operation with one expected contract. For example, lead.upsert_from_webform may use contact search, create, update, tags, custom fields, and owner assignment. Its V2 requirements include all scopes needed for those calls, the expected location context, the rate-limit class, and the fields the downstream workflow consumes.

HighLevel V2 applies rate limits per Marketplace app and per resource. The current FAQ documents a burst limit of 100 requests per 10 seconds and a daily limit of 200,000 requests per app per location or company. It also documents response headers for remaining burst and daily capacity. Those are official platform limits, not throughput guarantees for your workflow. Read the current values in the HighLevel API FAQ before designing a high-volume backfill.

The mapping matrix should include these dimensions:

Dimension

Migration question

Operation

What business outcome is intended?

V1 request

Which endpoint, method, headers, and payload are used now?

V2 request

Which endpoint and version header replace it?

Authentication

PIT or OAuth, agency or location token?

Scopes

Which exact read and write permissions are required?

Input mapping

Which internal fields map to provider fields?

Output contract

Which values must be normalized for downstream nodes?

Pagination

How are all pages consumed and checkpointed?

Retry class

Which failures are safe to retry?

Idempotency

What prevents repeated side effects?

Side effects

Which HighLevel workflows or external systems may fire?

Verification

How will you prove business equivalence?

I do not let raw provider responses flow across the whole workflow. The adapter validates and converts them into a stable internal shape. That protects downstream nodes from provider naming and nesting changes.

import { z } from "zod";

const HighLevelContactResponse = z.object({
  contact: z.object({
    id: z.string().min(1),
    locationId: z.string().min(1),
    dateAdded: z.string().optional()
  })
});

const ContactWriteResult = z.object({
  providerRecordId: z.string(),
  locationId: z.string(),
  created: z.boolean(),
  contractVersion: z.literal("contact-write.v2")
});

function normalizeContactResponse(
  raw: unknown,
  expectedLocationId: string,
  created: boolean
) {
  const parsed = HighLevelContactResponse.parse(raw);

  if (parsed.contact.locationId !== expectedLocationId) {
    throw new Error("GHL_TENANT_CONTEXT_MISMATCH");
  }

  return ContactWriteResult.parse({
    providerRecordId: parsed.contact.id,
    locationId: parsed.contact.locationId,
    created,
    contractVersion: "contact-write.v2"
  });
}

This code does more than type checking. It turns cross-location data into a hard error instead of letting the workflow continue with a valid but wrong record.

Scopes should be derived from the matrix, not guessed in the HighLevel UI. Start with the minimum set. Run contract tests. When a request returns 403, inspect whether the scope is genuinely required or whether the call is using the wrong token context. Adding broad scopes to silence permission errors recreates the V1 security model inside V2.

For OAuth, store the granted scopes with the installation. A code deployment may start using a new endpoint before older installations have consented to the new scope. Your feature gate should check installation capability before sending the call. Otherwise a rollout works for new tenants and fails for existing ones.

A specific example is a reporting workflow that reads contacts for every location. The naive design uses one agency token and loops over location IDs. The correct design depends on the endpoint's accepted auth context and the installation model. You may need to obtain or resolve a location access token, enforce per-location rate limits, and checkpoint pagination separately. A single global cursor is wrong because each location has its own result set and failure state.

[DIAGRAM] Draw the migration matrix as a flow from internal business command to V2 endpoint, scopes, auth context, normalized response, side effects, and verification. Include a separate branch for existing OAuth installs that do not yet have a newly required scope.

How do you cut over without duplicate contacts, appointments, or messages?

The safest cutover depends on whether the operation is a read or a write.

Reads can often be shadowed. Send the production input to the V1 read path and the V2 read path, use only the V1 result for the live decision, and compare normalized outputs asynchronously. This gives you real payload coverage without changing customer data. Search, list, and reporting operations are good candidates.

Writes are different. Dual-writing can create duplicate side effects. You cannot casually create the same appointment twice and compare the records later. For writes, I separate validation from mutation.

First, run V2 request construction in dry-run mode. Validate the canonical command, token context, scopes, transformed payload, and expected response schema without sending the write. For endpoints that offer no validation-only operation, compare against a controlled test location with production-like configuration.

Second, establish an idempotency boundary outside HighLevel. Every inbound event gets a stable key. The key should represent the business event, not the workflow execution. Retrying the same webhook creates a new n8n execution ID, so the execution ID cannot prevent duplicates.

create table integration_commands (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null,
  idempotency_key text not null,
  operation text not null,
  request_fingerprint text not null,
  status text not null,
  provider_record_id text,
  first_seen_at timestamptz not null default now(),
  completed_at timestamptz,
  last_error_code text,
  unique (tenant_id, idempotency_key, operation)
);

Third, make one component the writer. During cutover, route a controlled cohort to the V2 adapter. The cohort can be a test location, an internal location, a low-risk operation, or a deterministic percentage based on tenant ID. Do not route randomly per request because retries may hit different paths.

function useV2ForTenant(tenantId: string, enabledTenantIds: Set<string>) {
  return enabledTenantIds.has(tenantId);
}

Fourth, compare business outcomes. For a contact upsert, verify identity, tags, custom fields, owner, location, and downstream workflow enrollment. For an appointment, verify calendar, start time, timezone, contact association, assigned user, status, and notification behavior. For an opportunity, verify pipeline, stage, monetary value semantics, status, and duplicate rules.

Fifth, keep rollback at the routing layer. Rollback should not require editing ten n8n nodes. A feature flag or adapter selector should move new commands back to the old path. In-flight V2 commands must finish or be reconciled. Never replay the entire queue blindly after rollback.

Here is a practical contact-write sequence:

  1. Receive the source event and verify its signature.

  2. Normalize email and phone.

  3. Build a stable idempotency key from source system, source event ID, tenant, and operation.

  4. Insert the command with a unique constraint.

  5. Resolve the tenant's HighLevel installation.

  6. Confirm the installation location matches the command location.

  7. Choose V1 or V2 from a tenant-level feature flag.

  8. Search using the same identity policy on both paths.

  9. Write once.

  10. Persist the provider record ID and normalized result.

  11. Publish an internal completion event.

  12. Reconcile the actual HighLevel record asynchronously.

// [WORKFLOW JSON: n8n production cutover workflow showing signature verification, canonical payload mapping, Postgres idempotency insert, tenant-level V2 feature flag, adapter call, normalized result validation, and reconciliation enqueue. This proves the migration has one writer and a reversible routing decision.]

The rule is simple. Shadow reads. Cohort writes. Never dual-write customer-facing side effects unless you have isolated them and can prove they are non-triggering.

What breaks under token rotation, webhooks, and multi-location load?

A migration that survives one manual test is not production-ready. The failures arrive at boundaries, especially when multiple executions touch the same tenant at the same time.

PIT rotation is the simpler case. HighLevel documents a seven-day overlap during its “rotate and expire later” flow. Use that window intentionally. Store a credential version, deploy the new token, run synthetic checks, confirm all workers and automation platforms use the new version, then expire the old token. The overlap is not permission to leave both tokens scattered across systems. It is a controlled transition period.

OAuth refresh is state mutation. Two workers can see an expired access token and both attempt refresh. If each saves a different token pair, the last database write may not be the pair associated with the credentials other workers are using. Put a per-installation lock around refresh and recheck token state after acquiring the lock.

async function getValidAccessToken(installationId: string) {
  return db.transaction(async (tx) => {
    await tx.query(
      "select pg_advisory_xact_lock(hashtext($1))",
      [installationId]
    );

    const installation = await loadInstallationForUpdate(tx, installationId);

    if (installation.accessTokenExpiresAt > new Date(Date.now() + 60_000)) {
      return decrypt(installation.accessTokenCiphertext);
    }

    const refreshed = await refreshHighLevelToken(installation);
    await saveTokenPairAtomically(tx, installationId, refreshed);
    return refreshed.accessToken;
  });
}

The one-minute buffer in this example is a policy, not a HighLevel requirement. Choose your buffer based on request duration and clock handling. Record it as configuration.

Webhook verification has its own concurrency and parsing traps. Read the raw body before JSON parsing. Verify X-GHL-Signature using the official Ed25519 key. Reject invalid signatures before writing an inbox row. Once verified, persist the event and return quickly. Do not perform a long chain of CRM calls inside the webhook request.

import crypto from "node:crypto";

export function verifyGhlWebhook(
  rawBody: Buffer,
  signatureBase64: string,
  publicKeyPem: string
) {
  return crypto.verify(
    null,
    rawBody,
    publicKeyPem,
    Buffer.from(signatureBase64, "base64")
  );
}

Multi-location load exposes rate-limit mistakes. HighLevel documents burst and daily limits per app per resource. A global limiter can underuse capacity by forcing all locations through one queue. No limiter can create bursts that produce 429 responses. I use a keyed limiter by app and location or company, based on the resource being called. I also read the returned rate-limit headers instead of treating published limits as the only truth.

Backfills need a separate lane from live traffic. If you migrate historical contacts with the same priority as inbound leads, the backfill can consume the location's burst capacity and delay live writes. Tag jobs with a traffic class. Reserve capacity for live operations. Pause the backfill on sustained 429s or when remaining daily capacity approaches a policy threshold.

Webhooks can arrive out of order. A contact update event may be processed before the create event if one delivery is delayed. Your handler should query or upsert based on current provider state, not assume event order. Store provider event IDs when available, payload fingerprints, observed timestamps, and processing attempts.

A concrete failure is OAuth reinstallation. A location uninstalls and reinstalls your app. You now have a new installation state, possibly new scopes and token identifiers. Old queued jobs still reference the previous installation row. If your worker resolves a credential only once when the job is enqueued, it may keep retrying a revoked token. Resolve active installation state at execution time, and mark jobs tied to a revoked installation for review instead of retrying forever.

[DIAGRAM] Draw the token lifecycle for PIT and OAuth separately. PIT: current token, overlap window, new token, synthetic checks, old-token expiry. OAuth: access token, per-installation refresh lock, atomic token-pair update, retry original request, uninstall or revoke terminal state.

[WARNING] A 401 is not automatically retryable. It can mean an expired access token, a revoked installation, a malformed bearer token, or the wrong tenant credential. Classify before retrying. Repeated refresh attempts against a revoked installation create noise and can hide a real authorization change.

What does a production-grade migration test plan look like?

I test migrations in layers because a single end-to-end test cannot tell you where equivalence was lost.

The first layer is static inventory validation. Every known V1 endpoint and credential reference must map to a migration row. The build should fail if a workflow export or codebase still contains a forbidden V1 hostname or credential name after its scheduled decommission stage.

The second layer is request contract testing. Given a canonical command, the V2 adapter should produce the expected method, path, headers, query parameters, and payload. These tests do not call HighLevel. They catch accidental field removal, wrong location binding, and scope-to-operation mismatches early.

it("maps a canonical lead into a location-scoped V2 contact request", () => {
  const request = buildContactRequest(canonicalLeadFixture);

  expect(request.method).toBe("POST");
  expect(request.path).toBe("/contacts/");
  expect(request.headers.Version).toBe("2021-07-28");
  expect(request.body.locationId).toBe(canonicalLeadFixture.locationId);
  expect(request.body).not.toHaveProperty("tenantId");
});

The third layer is response contract testing. Save sanitized real responses from a test location and validate them against your parser. Include success, validation errors, 401, 403, 404, 409 if applicable, 422, 429, and provider 5xx responses. Do not make tests depend on error wording alone. Classify by status, stable error fields, and context.

The fourth layer is provider integration testing. Use a dedicated HighLevel test location configured like production. It needs representative custom fields, users, calendars, pipelines, stages, workflows, and permissions. An empty sandbox proves very little because many failures are configuration-dependent.

The fifth layer is workflow testing. Export or version the n8n workflow, run fixtures through test webhooks, and assert both data and side effects. A test should verify that a phone-only lead maps correctly, a missing optional field does not erase an existing value, a duplicate event returns the prior result, and a wrong location is rejected.

The sixth layer is failure testing. Deliberately expire or revoke a test token. Remove one required scope. Return a synthetic 429. Delay the provider call beyond your timeout. Send duplicate webhooks. Send events out of order. Kill a worker after the provider write but before the local command is marked complete. That last test reveals whether your retry produces a duplicate.

The seventh layer is load testing. Do not benchmark HighLevel by flooding production. Load-test your receiver, queue, idempotency store, credential resolver, and adapter with mocked provider responses. Then perform a controlled provider test within documented limits. Watch queue depth, lock wait time, database connections, rate-limit headers, and failure classification.

The eighth layer is cutover observation. For each cohort, define a monitoring window and explicit rollback criteria. Compare V1 baseline metrics with V2 outcomes, but do not invent a universal threshold. A client with low lead volume may require record-by-record verification. A high-volume operation may use sampled reconciliation plus zero tolerance for certain error classes.

A useful migration dashboard shows commands by operation and adapter version, success by status class, 401/403/429 counts, token refresh attempts, idempotency conflicts, reconciliation differences, queue age, and unresolved failures. It should allow filtering by tenant and location without exposing contact data.

// [WORKFLOW JSON: n8n error workflow that receives structured failures, classifies auth, permission, validation, rate-limit, and provider errors, then writes an incident-safe record without exposing contact payloads or tokens. This proves failures are actionable rather than dumped into one generic alert.]

The final test is decommissioning. Disable the old route, do not delete it immediately, and monitor for attempted use. If any job still calls V1, the attempt should fail loudly inside your own adapter with the operation and source identified. After the agreed observation period, revoke the old key, remove secret references, delete dead code, and archive the migration evidence.

What does the safer architecture cost you?

The safer approach costs more than replacing URLs.

You need a credential model. OAuth requires encrypted token storage, refresh logic, per-installation locking, install and uninstall handling, scope tracking, and operational tools for support. Even PITs require ownership, rotation, versioning, and a way to know which workflows use the token.

You need a data boundary. Canonical commands, schema validation, normalized responses, and idempotency tables create code and database objects that a direct n8n-to-HighLevel workflow avoids. Somebody must own those contracts.

You need infrastructure. A webhook inbox, queue, Postgres database, logs, metrics, and feature flags are not free. They add deployment and on-call responsibilities. If the automation is genuinely small, internal, low-risk, and tied to one location, a well-built n8n workflow with a scoped PIT may be enough. I do not add a custom service to every three-node workflow.

The decision hinges on failure cost and change frequency. If the workflow creates revenue-facing records, spans many locations, handles OAuth installs, performs high-volume sync, or must survive provider changes, code around the automation platform earns its keep. n8n remains the orchestration layer. The adapter and state live where concurrency and correctness are easier to test.

There is also a speed tradeoff. During migration, a team may want to preserve every V1 behavior exactly. Some behavior should not be preserved. Broad permissions, shared keys, hard-coded location IDs, direct side effects, and unbounded retries are defects. Correcting them increases project scope. That is the right scope increase, but it needs to be named early.

A practical architecture split looks like this:

Responsibility

Best home

Trigger visibility and business orchestration

n8n or Make

Tenant installation and token lifecycle

Custom service with Postgres

Provider request construction and response validation

Typed adapter in code

Idempotency and command state

Postgres

Human review and replay

Internal admin page or controlled workflow

Simple one-location internal action

n8n with scoped PIT, when risk is low

Marketplace installation

OAuth service, not a copied workflow credential

I would avoid putting OAuth refresh logic in a generic n8n Code node copied into many workflows. The node can work, but copied stateful auth logic drifts. One workflow gains a lock. Another does not. One saves the new refresh token. Another saves only the access token. One retries a failed request. Another refreshes and forgets to replay. Centralize it.

I would also avoid turning the adapter into a giant internal platform before the migration proves its boundaries. Start with the business operations in the inventory. Build only the token resolver, command table, provider client, and observability required for those operations. Generalize after two or three real flows reveal the shared contract.

[REAL NUMBER] Insert the actual engineering and operational cost after the migration: implementation time by workstream, infrastructure cost, ongoing support time, and incident reduction. Label any figure that is not yet measured as unverified.

The trade is simple. You accept more explicit machinery in exchange for fewer invisible assumptions.

What would I do differently on the next HighLevel migration?

I would start with event and data ownership before endpoint mapping.

On earlier integration work, it is easy to ask, “Which V2 endpoint replaces this V1 endpoint?” The better question is, “Which system owns this fact, and what event is allowed to change it?” That question prevents loops where HighLevel updates Shopify, Shopify updates an internal database, and the database writes the same value back to HighLevel.

I would create the canonical command and idempotency table before touching authentication. Once every provider write enters through a named operation, migration becomes a routing change. Without that boundary, every workflow carries its own migration logic.

I would separate credential migration from functional migration. First prove that each tenant can obtain and use the correct V2 auth context for a harmless read. Then migrate one business operation at a time. When auth and behavior change together, every failure has too many possible causes.

I would preserve raw request evidence more carefully, within privacy limits. Not full contact data. A redacted fixture library with payload variants, response shapes, and correlation IDs. The rare payload is usually the one that breaks after cutover, such as a phone-only lead, a contact with duplicate email history, a calendar in a different timezone, or an opportunity missing an optional value.

I would test HighLevel workflow side effects explicitly. A provider API write can be correct while the client's internal HighLevel workflows react differently. For each critical operation, I would document which HighLevel workflows, triggers, campaigns, notifications, and webhooks are expected to fire. Then I would verify them during cohort rollout.

I would make scope changes a product event. When a new release needs another HighLevel scope, existing OAuth installations may not have it. The release plan needs consent or reinstallation handling, feature gating, and support messaging. Scopes are not just a developer setting.

I would keep a tenant kill switch. If one location enters a duplicate loop, I want to stop writes for that location without disabling the integration for every client. The switch should block mutation, preserve inbound events, and allow controlled replay after the issue is fixed.

alter table ghl_installations
add column writes_enabled boolean not null default true,
add column writes_disabled_reason text,
add column writes_disabled_at timestamptz;

I would also put deprecation monitoring into the maintenance agreement. HighLevel has current API docs, webhook guidance, SDKs, and changelogs. A production integration should have an owner who reviews platform changes and tests them before they become client incidents. “The workflow was finished last year” is not an operating model.

A concrete example is HighLevel's webhook signature transition. If your receiver still validates only X-WH-Signature, the API migration can appear complete while a separate September 1, 2026 deprecation is waiting. A dependency register should include authentication, endpoint, webhook, SDK, and provider-policy changes, not just the main migration headline.

The biggest improvement is procedural. I would refuse a big-bang cutover unless the existing system is so small that it can be fully proven in one controlled window. Feature-by-feature migration is slower to schedule and much faster to debug.

What is still hard after every API call returns 200?

The hard part is semantic equivalence.

You can prove that V2 accepted the request. You can prove that the contact exists. You can prove that the token has the right scope. None of that proves the client received the same operational outcome.

A lead can exist with the wrong owner. An appointment can exist in the wrong timezone. An opportunity can exist in the right pipeline but the wrong stage. A webhook can be authentic and still be processed twice. A refresh flow can be correct and still select the wrong tenant installation. A migration can have zero HTTP errors while attribution, routing, notifications, and follow-up quietly drift.

This is the loop from the opening. The missing custom field was never a transport problem. It was part of a business contract that lived implicitly inside an old workflow. V2 forces you to expose that contract, but the API cannot define it for you.

The final migration artifact I care about is not a list of replaced endpoints. It is a list of business invariants. For every critical operation, write down what must remain true after retries, duplicate events, token rotation, partial failure, out-of-order webhooks, and a provider timeout that occurs after the remote write but before your system receives the response.

Then test those invariants.

One line offering to look at a setup is enough: I will look at a V1 inventory or migration matrix and point out where the hidden state is likely to break the cutover.

The part most teams still get wrong is deciding what “the same result” means before they change the system that was producing it.