Claude
Skills
Sign in
Back

hubspot-agency-multi-portal

Included with Lifetime
$97 forever

Manage 10-100 HubSpot portals for agency clients with credential isolation that prevents cross-portal data contamination, per-portal audit trails for billing and GDPR/CCPA attribution, and a scriptable bulk-onboarding workflow that eliminates one-at-a-time credential setup. Use when onboarding new client portals, building a compliant per-client API call log, rotating tokens across a full agency fleet, or generating per-client compliance reports. Trigger with "hubspot agency", "multi-portal management", "hubspot credential isolation", "per-portal audit log", "hubspot compliance report", "bulk portal onboarding", "token rotation cascade", "hubspot client portals".

Backend & APIshubspotmulti-portalagencycomplianceaudit

What this skill does


# HubSpot Agency Multi-Portal

## Overview

Operate a fleet of HubSpot portals for agency clients without cross-portal contamination, attribution loss, or onboarding bottlenecks. This is not a getting-started guide — it is the infrastructure your agency runs on day one with client one and scales to client one hundred without revisiting.

The six production failures this skill prevents:

1. **Cross-portal credential contamination** — a shared `HUBSPOT_ACCESS_TOKEN` env var causes API writes intended for Client A to silently land in Client B's CRM. The HubSpot API does not reject the call; it accepts it. Data corruption is silent and may not be discovered for days. Per-portal credential isolation — enforced in code, not convention — is the only fix.
2. **Audit trail gaps** — agency billing, SLA compliance, and GDPR/CCPA data-processing agreements all require proof of which API calls were made on behalf of which client. A shared token makes post-hoc attribution impossible. A per-portal structured audit log with portalId, clientSlug, operation, and timestamp makes attribution irrefutable.
3. **Bulk onboarding bottleneck** — onboarding 50 new clients one-at-a-time requires 50 manual credential setups, 50 manual verifications, and 50 opportunities for human error. A scriptable bulk onboarding workflow reads a CSV of client names and tokens, validates each against the account-info endpoint, and seeds the credential store in one pass.
4. **Token rotation cascade** — rotating one client's private-app token in HubSpot does not update any downstream system. With 50 portals, a partial rotation — some systems updated, some not — leaves stale tokens in production for undetermined periods. A per-portal rotation runbook with a cross-system checklist closes the gap.
5. **Rate-limit aggregation confusion** — each portal has its own independent 500K/day quota. An agency analytics system reading all 50 portals is NOT limited to 500K calls total — it has 500K per portal per day, but only if the token used for each portal belongs to that portal. A shared token collapses all quota attribution to one portal, causing artificial exhaustion and incorrect monitoring.
6. **Compliance reporting ambiguity** — under GDPR Article 30 and CCPA, a data processor (the agency) must demonstrate which operations were performed on which controller's (client's) data and when. A shared token makes this demonstration impossible after the fact. Per-portal audit logs with structured fields make it a simple query.

## Prerequisites

- Node.js 18+ or Python 3.10+
- One HubSpot private-app token per client portal (Settings → Integrations → Private Apps → Create private app → Auth tab)
- A secret store the credential router can read at startup: AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, or `pass` for local development
- `jq` installed for CLI validation steps
- `python3` with standard library only for bulk onboarding script (no external deps required)
- CSV file of client slugs and tokens for bulk onboarding (format: `clientSlug,portalToken,portalId`)

## Instructions

Build in this order. Each section closes one production failure mode.

### 1. Credential store design (closes cross-portal contamination)

The credential store is a JSON map of `clientSlug → token`. It lives in your secret manager, never in source code or environment variables. The key insight is that `clientSlug` is the primary key — every operation starts by selecting a slug, which deterministically selects the token. There is no ambient credential and no fallback to a global env var.

```typescript
// Shape of the credential store (stored in secret manager, NOT in git or env vars)
interface PortalCredentialStore {
  version: number; // increment on every write; used for drift detection
  portals: Record<string, PortalCredential>;
}

interface PortalCredential {
  token: string;            // HubSpot private-app token: pat-na1-...
  portalId: number;         // HubSpot portal ID — verified via account-info on onboard
  clientSlug: string;       // kebab-case client identifier: "acme-corp"
  addedAt: string;          // ISO 8601 — when this credential was seeded
  lastRotatedAt: string;    // ISO 8601 — updated on every token rotation
  datacenter: string;       // "na1" | "eu1" | "au1" — extracted from token prefix
}

// Load from secret manager at process startup — never re-read per-request
async function loadCredentialStore(): Promise<PortalCredentialStore> {
  const raw = await readSecret("hubspot/agency-portals");
  const store: PortalCredentialStore = JSON.parse(raw);
  if (!store.portals || typeof store.portals !== "object") {
    throw new Error("Credential store is malformed — missing portals map");
  }
  return store;
}
```

The `datacenter` field matters: a `pat-na1-*` token sent to `api.hubapi.com` (which routes to `na1`) will work, but if HubSpot migrates the portal to `eu1`, the token prefix changes and calls to the wrong datacenter return 404. Storing the datacenter alongside the token surfaces this mismatch immediately.

### 2. Portal identity verification (confirm token points to expected portal)

Every token must be verified against the `GET /account-info/v3/details` endpoint before being admitted to the credential store. This endpoint returns the portalId for the token's portal — which is the ground truth for "which portal does this token belong to."

```typescript
interface PortalDetails {
  portalId: number;
  timeZone: string;
  currency: string;
  portalType: string; // "STANDARD" | "DEVELOPER" | "SANDBOX" | "TRIAL"
}

async function verifyPortalIdentity(
  token: string,
  expectedPortalId?: number
): Promise<PortalDetails> {
  const res = await fetch("https://api.hubapi.com/account-info/v3/details", {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (res.status === 401) {
    throw new Error("Token rejected (401) — revoked, malformed, or wrong datacenter");
  }
  if (res.status === 403) {
    throw new Error("Token lacks account-info scope — re-create private app with account-info scope");
  }
  if (!res.ok) {
    throw new Error(`account-info returned ${res.status}: ${await res.text()}`);
  }

  const details: PortalDetails = await res.json();

  if (expectedPortalId !== undefined && details.portalId !== expectedPortalId) {
    throw new Error(
      `Portal ID mismatch — token belongs to portal ${details.portalId}, ` +
      `expected ${expectedPortalId}. Token is for the wrong client.`
    );
  }

  return details;
}
```

Run `verifyPortalIdentity` during onboarding (to populate `portalId` in the credential store) and during rotation (to confirm the new token belongs to the same portal before committing it).

### 3. Per-portal HTTP client factory with audit-log middleware (closes audit trail gaps)

The client factory produces an HTTP client bound to a single portal's token. Every request made through this client is logged to the audit trail with a structured record. There is no way to make an unlogged HubSpot API call through this factory — the audit middleware is non-optional.

```typescript
interface AuditRecord {
  ts: string;           // ISO 8601 timestamp
  portalId: number;
  clientSlug: string;
  method: string;       // GET | POST | PATCH | DELETE
  path: string;         // /crm/v3/objects/contacts
  statusCode: number;
  durationMs: number;
  objectType?: string;  // "contacts" | "deals" | "companies" — extracted from path
  objectId?: string;    // from path or response body
  actor: string;        // "hubspot-agency-router/2.0.0"
  rateLimitRemaining?: number; // from X-HubSpot-RateLimit-Daily-Remaining header
}

type AuditWriter = (record: AuditRecord) => void | Promise<void>;

function createPortalClient(
  credential: PortalCredential,
  auditWriter: AuditWriter
) {
  const baseUrl = "https://api.hubapi.com";

  return async function portalFetch(
    path: string,
    init: RequestInit = {}
  ): Promise<Response> {
    const start = Date.now();
    const metho

Related in Backend & APIs