Skip to main content

Adding a new client

The most-repeated operational task in the platform, start to finish. Follow it in order; several later steps fail silently if an earlier one was skipped.

Source-reviewed on 2026-08-26. Verify current production behavior and credentials separately.

Who can do this

The whole /admin/* segment is gated by isAdminTier() in src/app/admin/layout.tsx — super_admin, admin, or account_manager. Some individual panels require the stricter view_platform_admin permission (super_admin and admin only); an account_manager who reaches those URLs gets a forbidden state rather than data.

The client hub​

Almost everything below happens on one screen: /admin/clients/[slug]. Its sections are deep-linkable via ?tab=, and the tab keys are exactly:

Tab keyLabel
overviewOverview
brandsBrands
accountsAd accounts
usersUsers
feesFees
metricsMetrics
targetsTargets

An unrecognized ?tab= value falls back to overview. The legacy ?section= param is still honored for old bookmarks.

Note that /admin/brands and /admin/ad-accounts are redirects to /admin/clients — cross-tenant management of those objects was folded into this hub, because a brand or an ad account is only meaningful as a binding to a client.


1. Create the client record​

Where: /admin/clients → create form.

Sets the client's slug, which is the tenant key for the entire system. The slug becomes the ClickHouse client_id stamped on every ingested row, so it is not cosmetic and should not be changed after data exists.

Verify: the new client appears in the /admin/clients table with status Active.

2. Set brands​

Where: /admin/clients/[slug]?tab=brands

Brands are the sub-tenant grouping below client (brands table, migration 005). Add them before binding ad accounts if the client's accounts split by brand.

3. Bind ad accounts and enter connector credentials​

Where: /admin/clients/[slug]?tab=accounts, and /marketing/settings → Connectors for the same client.

A binding is a row in client_ad_accounts linking one client to one account or source on one platform:

  • platform — a key from the maintained connector/platform registry; the table now hosts paid, commerce, CRM, warehouse, and attribution bindings.
  • account_id — the provider-specific account, shop, property, or source identifier.
  • credential_ref — a pointer to the encrypted credential, never the credential itself.
  • status — active / paused / archived.

There is a unique (platform, account_id) constraint: an ad account belongs to exactly one client. If a bind fails on uniqueness, the account is already bound elsewhere — find and unbind it rather than working around the constraint.

Credentials are not env vars

Per-client platform credentials live encrypted in the connector vault, referenced by credential_ref. Those stored values use the AES-256-GCM envelope in src/lib/crypto.ts and fail closed when CREDENTIAL_ENCRYPTION_KEY is unset. Platform-service reads have their own store and may use documented environment fallbacks. See Credentials.

Platform display names are normalized for the UI (facebook renders as "Meta Ads"). Do not let that fool you when querying — the stored value is the raw platform slug. Related trap: platform=instagram is normalized to facebook by canonicalPaidDataPlatform during sync; without that normalization it matches no stage and the whole sync no-ops.

4. Set the canonical ClickUp code​

Where: the client record, clients.clickup_code (migration 053_client_clickup_code.sql).

Shape constraint: 2–8 uppercase alphanumerics (^[A-Z0-9]{2,8}$), unique across clients when set (a partial unique index, so any number of clients may remain code-less).

Before 053 the ClickUp project binder derived codes at runtime from a name regex. That heuristic (src/lib/clickup/project-association.ts) is now demoted to a suggestion-only fallback for code-less clients. Set the code explicitly — leaving it null means the binder is guessing.

Associate the client's ClickUp list/project so creative ingestion routes to the right tenant.

5. Configure CRM / commerce credentials​

Where: per-provider, through the connectors in src/lib/marketing/platforms/.

Supported CRM, commerce, warehouse, and attribution sources are maintained in the connector reference, including Shopify, Hyros, Konnektive, Sticky.io, UltraCart, CAKE, Checkout Champ, ClickBank, Northbeam, Converge/BigQuery, PostHog, and Postman. Bindings are re-resolved inside each sync run and never carried on the event payload, so a paused binding drops out immediately without needing a redeploy.

6. Set crm_purchase_field​

Where: the client record (clients.crm_purchase_field).

This decides which CRM count the platform treats as a "purchase". The resolution ladder in src/lib/marketing/crm-source.ts (deriveCrmSource) is:

  1. client.crm_purchase_field — the typed column, wins.
  2. client.v1_import.hyros_purchase_metric — the V1 port fallback.
  3. Default 'unique_customers' if neither is set.

What breaks when it is wrong: nothing errors. The client's purchase and revenue numbers are simply computed off the wrong CRM metric, so every downstream figure — conversions, CPA, ROAS, and the whole attribution ledger — is quietly wrong by a consistent factor. Because the default is silently applied, a client that should be on unique_sales and was never configured will look plausible and be incorrect. Confirm this value with the account owner rather than accepting the default.

The CRM source itself resolves by a parallel ladder: explicit client.crm_source → the migrated v1_import.crm_type → a single active CRM binding. Multiple distinct CRM bindings are intentionally ambiguous and fail closed — the client resolves to no CRM source at all. If CRM data is missing entirely, check for two competing bindings before anything else.

7. Configure fee terms​

Where: /admin/clients/[slug]?tab=fees

Fee terms feed margin and net-revenue reporting.

Also set the client's gross-margin percentage on Overview (clients.gp_margin) when gross-profit metrics are in scope. Gross margin is not the same as an agency fee. Configure product targets and product alias/exclusion rules for clients whose reporting depends on product-level goals or normalized product names; verify the current migrations and UI before bulk import.

Historical fee evidence

Do not promise historical fee accuracy from the presence of a Fees tab alone. Verify that the client has dated fee-term rows covering the requested window and confirm the current resolver behavior in source/runtime.

8. Create client users and assign roles​

Where: /admin/clients/[slug]?tab=users — not /admin/users.

/admin/users is the platform-wide admin user registry. Client-scoped users are managed on the client's own Users tab. Getting this wrong creates a user with no tenant binding.

Roles come from the single source of truth, src/lib/rbac.ts. A client_user is locked to their own clientSlug: request parameters asking for another tenant are ignored, not honored. This is enforced server-side in resolveQueryScope() (src/lib/auth-scope.ts) — never in the browser.

9. Set client-level metric thresholds​

Where: /admin/clients/[slug]?tab=metrics

Backed by client_metric_settings (migration 031). These drive per-client thresholds and metric display in reporting.

10. Grant MCP access (if needed)​

Where: /admin/mcp

Only if this client's data should be reachable by AI clients through the MCP server. MCP is read-mostly over OAuth 2.1; mutating tools require the separate mcp:write scope. Tenant scoping applies identically — resolveMcpScope fails closed on an unknown client hint and ignores supplied hints for tenant-locked callers.


Verify​

Do not consider the client onboarded until all four pass.

  1. Run a sync. Trigger a manual sync for the client from /admin/sync-health, or run npm run sync:marketing -- --client <slug> against the exact authorized target. Do not omit --client: the CLI otherwise targets all clients. A manual sync runs everything including paid fetches—scheduled unscoped runs deliberately do not.
  2. /admin/sync-health is green for this client's accounts. Check the per-account rows, and the cron_liveness block if you also changed anything scheduled.
  3. The client renders at /marketing?client=<slug> and the selected range has an explainable freshness/availability state. Valid zero activity is not a failure and must remain distinct from missing or unavailable data.
  4. Run the relevant parity gate. At minimum npm run audit:v1-v2-client-metrics; see the runbooks index for the full gate list.

Triage: the first sync returned nothing​

Work down this list in order — it is ordered by observed frequency.

CheckHowIf wrong
Is the binding active?client_ad_accounts.statusA paused/archived binding is skipped silently.
Does the binding have usable credentials?/marketing/settings → ConnectorsA binding with no usable creds is a logged no-op. It never fabricates data and never falls back to another tenant's account — so "no data" is the correct, safe outcome, not a bug.
Is CREDENTIAL_ENCRYPTION_KEY set in this environment?Vercel env storeStored per-client credentials fail closed without it; platform-service fallbacks must be checked separately.
Is the platform value normalized?binding rowinstagram must canonicalize to facebook. A raw unnormalized platform matches no stage and the sync no-ops.
Did the account actually get picked up by the conveyor?Inngest dashboardScheduled paid fetching is a conveyor: paidAccountConveyor dispatches exactly one least-recently-synced paid binding per */10 tick, fleet-wide. A brand-new account sorts first (never-synced), but a large fleet still means a wait. Do a manual sync to prove the credentials rather than waiting on the conveyor.
Is the binding env-configured rather than a database row?binding row has no idEnv-configured bindings with no row id cannot ride the conveyor at all — they only ever sync via manual full-client syncs.
Did the ad account get bound to the wrong client?unique (platform, account_id)The account can only belong to one client; check whether an older tenant holds it.
Is there more than one active CRM binding?deriveCrmSourceMultiple distinct CRM sources fail closed — no CRM data resolves at all.
Was crm_purchase_field left unset?client recordNot a "nothing returned" cause, but the reason numbers are present and wrong. See step 6.

If paid data arrives but Meta device/country segment cuts are missing: those are the page hogs and refresh only on hour-divisible-by-6 cycles (heavyCutsDue). Age/gender/all-grain run every pass. Missing segment cuts within a 6-hour window are expected, not a failure.

See also​

  • Connectors — per-platform status and auth model.
  • Credentials — vault layout and rotation.
  • Sync failures — when a sync starts failing after it once worked.
  • The Inngest folder guide (src/inngest/AGENTS.md) — the authoritative current sync model.