diff --git a/app/onboarding/roster/page.tsx b/app/onboarding/roster/page.tsx
new file mode 100644
index 000000000..93d2abfc0
--- /dev/null
+++ b/app/onboarding/roster/page.tsx
@@ -0,0 +1,5 @@
+import RosterSocialsFlow from "@/components/Onboarding/RosterSocialsFlow";
+
+const OnboardingRoster = () => ;
+
+export default OnboardingRoster;
diff --git a/components/Onboarding/AddArtistForm.tsx b/components/Onboarding/AddArtistForm.tsx
new file mode 100644
index 000000000..3c32b393c
--- /dev/null
+++ b/components/Onboarding/AddArtistForm.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useState } from "react";
+import { Loader, Plus } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useAddRosterArtist } from "@/hooks/onboarding/useAddRosterArtist";
+
+/** Inline "add another artist" form for multi-artist managers. */
+const AddArtistForm = () => {
+ const { addArtist, isAdding } = useAddRosterArtist();
+ const [isOpen, setIsOpen] = useState(false);
+ const [name, setName] = useState("");
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const added = await addArtist(name);
+ if (added) {
+ setName("");
+ setIsOpen(false);
+ }
+ };
+
+ if (!isOpen) {
+ return (
+ setIsOpen(true)}
+ >
+
+ Add another artist
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default AddArtistForm;
diff --git a/components/Onboarding/ArtistSocialsCard.tsx b/components/Onboarding/ArtistSocialsCard.tsx
new file mode 100644
index 000000000..aa0cccd13
--- /dev/null
+++ b/components/Onboarding/ArtistSocialsCard.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import type { ArtistRecord } from "@/types/Artist";
+import SocialFixForm from "./SocialFixForm";
+import SocialRow from "./SocialRow";
+
+interface ArtistSocialsCardProps {
+ artist: ArtistRecord;
+ isFixing: boolean;
+ onFix: (url: string) => Promise;
+}
+
+/**
+ * Per-artist socials, accepted by default: each auto-matched profile is
+ * shown with an Edit affordance to fix a wrong match; an artist with no
+ * matches gets a soft nudge to add one. No confirm/none step — the step
+ * proceeds regardless (see VerifySocialsStep).
+ */
+const ArtistSocialsCard = ({
+ artist,
+ isFixing,
+ onFix,
+}: ArtistSocialsCardProps) => {
+ const socials = artist.account_socials ?? [];
+ const [adding, setAdding] = useState(false);
+
+ const handleAdd = async (url: string) => {
+ const saved = await onFix(url);
+ if (saved) setAdding(false);
+ return saved;
+ };
+
+ return (
+
+
+ {artist.name || "Untitled artist"}
+
+
+ {socials.length === 0 ? (
+
+ No profiles were auto-matched for {artist.name || "this artist"}. Add
+ one, or continue.
+
+ ) : (
+
+ {socials.map((social) => (
+
+ ))}
+
+ )}
+
+ {/* Always available: a matched-social list can still be missing platforms. */}
+ {adding ? (
+
+ ) : (
+
setAdding(true)}
+ >
+ Add a profile
+
+ )}
+
+ );
+};
+
+export default ArtistSocialsCard;
diff --git a/components/Onboarding/ConfirmRosterStep.tsx b/components/Onboarding/ConfirmRosterStep.tsx
new file mode 100644
index 000000000..67fdd9e3f
--- /dev/null
+++ b/components/Onboarding/ConfirmRosterStep.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useArtistProvider } from "@/providers/ArtistProvider";
+import AddArtistForm from "./AddArtistForm";
+import RosterArtistRow from "./RosterArtistRow";
+
+/**
+ * Onboarding step: confirm the auto-created roster. Shows the artists
+ * the valuation flow created, lets multi-artist managers add the rest,
+ * and advances once the user confirms the list.
+ */
+const ConfirmRosterStep = ({ onConfirmed }: { onConfirmed: () => void }) => {
+ const { sorted, isLoading } = useArtistProvider();
+ const artists = sorted.filter((artist) => !artist.isWorkspace);
+
+ return (
+
+
+
+ Confirm your roster
+
+
+ We set {artists.length === 1 ? "this artist" : "these artists"} up
+ from your valuation. Add anyone else you manage — you can verify their
+ socials next.
+
+
+
+
+ {isLoading ? (
+ <>
+
+
+ >
+ ) : artists.length === 0 ? (
+
+ No artists on your roster yet. Add your first artist below.
+
+ ) : (
+ artists.map((artist) => (
+
+ ))
+ )}
+
+
+
+
+ {artists.length > 1 ? "These are my artists" : "This is my artist"} —
+ continue
+
+
+ );
+};
+
+export default ConfirmRosterStep;
diff --git a/components/Onboarding/RosterArtistRow.tsx b/components/Onboarding/RosterArtistRow.tsx
new file mode 100644
index 000000000..47b8e64ce
--- /dev/null
+++ b/components/Onboarding/RosterArtistRow.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import type { ArtistRecord } from "@/types/Artist";
+
+const RosterArtistRow = ({ artist }: { artist: ArtistRecord }) => {
+ const socialsCount = artist.account_socials?.length ?? 0;
+ const socialsLabel =
+ socialsCount === 0
+ ? "No socials matched yet"
+ : `${socialsCount} social ${socialsCount === 1 ? "profile" : "profiles"} matched`;
+
+ return (
+
+
+ {artist.image ? : null}
+
+ {(artist.name || "?").charAt(0).toUpperCase()}
+
+
+
+
+ {artist.name || "Untitled artist"}
+
+
{socialsLabel}
+
+
+ );
+};
+
+export default RosterArtistRow;
diff --git a/components/Onboarding/RosterSocialsFlow.tsx b/components/Onboarding/RosterSocialsFlow.tsx
new file mode 100644
index 000000000..203633aa8
--- /dev/null
+++ b/components/Onboarding/RosterSocialsFlow.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useState } from "react";
+import ConfirmRosterStep from "./ConfirmRosterStep";
+import RosterVerifiedPanel from "./RosterVerifiedPanel";
+import VerifySocialsStep from "./VerifySocialsStep";
+
+type FlowStep = "roster" | "socials" | "done";
+
+/**
+ * Standalone container for the roster + socials onboarding steps so the
+ * slice is user-testable at /onboarding/roster today. The sibling
+ * onboarding-sequence router (chat#1867) mounts `ConfirmRosterStep` and
+ * `VerifySocialsStep` directly with its own state-derived stepping.
+ */
+const RosterSocialsFlow = () => {
+ const [step, setStep] = useState("roster");
+
+ return (
+
+ {step !== "done" && (
+
+ Step {step === "roster" ? "1" : "2"} of 2
+
+ )}
+ {step === "roster" && (
+
setStep("socials")} />
+ )}
+ {step === "socials" && (
+ setStep("done")} />
+ )}
+ {step === "done" && }
+
+ );
+};
+
+export default RosterSocialsFlow;
diff --git a/components/Onboarding/RosterVerifiedPanel.tsx b/components/Onboarding/RosterVerifiedPanel.tsx
new file mode 100644
index 000000000..aba0726f1
--- /dev/null
+++ b/components/Onboarding/RosterVerifiedPanel.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import Link from "next/link";
+import { CheckCircle2 } from "lucide-react";
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+/** Shown when both roster and socials steps are complete. */
+const RosterVerifiedPanel = () => (
+
+
+
+
+ Roster verified
+
+
+ Your artists and their socials are confirmed. Reports, research, and
+ tasks will use them from here on.
+
+
+
+ Back to Recoup
+
+
+);
+
+export default RosterVerifiedPanel;
diff --git a/components/Onboarding/SocialFixForm.tsx b/components/Onboarding/SocialFixForm.tsx
new file mode 100644
index 000000000..3699ea268
--- /dev/null
+++ b/components/Onboarding/SocialFixForm.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useState } from "react";
+import { Loader } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+interface SocialFixFormProps {
+ placeholder: string;
+ isSubmitting: boolean;
+ onSubmit: (url: string) => Promise;
+}
+
+/** Paste-a-link form used to replace a wrong social or add a missing one. */
+const SocialFixForm = ({
+ placeholder,
+ isSubmitting,
+ onSubmit,
+}: SocialFixFormProps) => {
+ const [url, setUrl] = useState("");
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const saved = await onSubmit(url);
+ if (saved) setUrl("");
+ };
+
+ return (
+
+ );
+};
+
+export default SocialFixForm;
diff --git a/components/Onboarding/SocialRow.tsx b/components/Onboarding/SocialRow.tsx
new file mode 100644
index 000000000..ab70bbd66
--- /dev/null
+++ b/components/Onboarding/SocialRow.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useState } from "react";
+import { Pencil } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import getSocialPlatformByLink from "@/lib/getSocialPlatformByLink";
+import { getSocialFollowerCount } from "@/lib/onboarding/getSocialFollowerCount";
+import getPlatformDisplayName from "@/lib/socials/getPlatformDisplayName";
+import formatFollowerCount from "@/lib/utils/formatFollowerCount";
+import SocialFixForm from "./SocialFixForm";
+import type { SOCIAL } from "@/types/Agent";
+
+interface SocialRowProps {
+ social: SOCIAL;
+ isSubmitting: boolean;
+ onFix: (url: string) => Promise;
+}
+
+/**
+ * One auto-matched social, accepted by default: platform · handle ·
+ * followers, with an Edit affordance that reveals a paste-the-correct-link
+ * form for the rare wrong match. No confirm step — leaving it as-is is the
+ * confirmation.
+ */
+const SocialRow = ({ social, isSubmitting, onFix }: SocialRowProps) => {
+ const [editing, setEditing] = useState(false);
+ const platform = getPlatformDisplayName(
+ getSocialPlatformByLink(social.link || ""),
+ );
+ const followerCount = getSocialFollowerCount(social);
+ const handle = social.username
+ ? social.username.startsWith("@")
+ ? social.username
+ : `@${social.username}`
+ : social.link;
+
+ const handleFix = async (url: string) => {
+ const saved = await onFix(url);
+ if (saved) setEditing(false);
+ return saved;
+ };
+
+ return (
+
+
+
+
{platform}
+
+ {handle}
+ {followerCount !== null &&
+ ` · ${formatFollowerCount(followerCount)} followers`}
+
+
+
setEditing((open) => !open)}
+ >
+
+
+
+ {editing && (
+
+
+
+ )}
+
+ );
+};
+
+export default SocialRow;
diff --git a/components/Onboarding/VerifySocialsStep.tsx b/components/Onboarding/VerifySocialsStep.tsx
new file mode 100644
index 000000000..bef078593
--- /dev/null
+++ b/components/Onboarding/VerifySocialsStep.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { useSocialFix } from "@/hooks/onboarding/useSocialFix";
+import { useArtistProvider } from "@/providers/ArtistProvider";
+import ArtistSocialsCard from "./ArtistSocialsCard";
+
+/**
+ * Onboarding step: review the auto-matched socials for every rostered
+ * artist. Matches are accepted by default — edit any that point at the
+ * wrong account, or add one where none were found. Continue always
+ * proceeds; an artist with no socials simply has none.
+ */
+const VerifySocialsStep = ({ onConfirmed }: { onConfirmed: () => void }) => {
+ const { sorted } = useArtistProvider();
+ const artists = sorted.filter((artist) => !artist.isWorkspace);
+ const { fixSocial, fixingArtistId } = useSocialFix();
+
+ return (
+
+
+
+ Verify socials
+
+
+ These are the profiles we matched. Fix any that point at the wrong
+ account — reports and tasks pull from them.
+
+
+
+
+ {artists.map((artist) => (
+
fixSocial(artist, url)}
+ />
+ ))}
+
+
+
+ Looks good — continue
+
+
+ );
+};
+
+export default VerifySocialsStep;
diff --git a/hooks/onboarding/useAddRosterArtist.ts b/hooks/onboarding/useAddRosterArtist.ts
new file mode 100644
index 000000000..965ec3d6f
--- /dev/null
+++ b/hooks/onboarding/useAddRosterArtist.ts
@@ -0,0 +1,44 @@
+"use client";
+
+import { useState } from "react";
+import { usePrivy } from "@privy-io/react-auth";
+import { toast } from "sonner";
+import { createRosterArtist } from "@/lib/artists/createRosterArtist";
+import { useArtistProvider } from "@/providers/ArtistProvider";
+import { useOrganization } from "@/providers/OrganizationProvider";
+
+/**
+ * "Add another artist" for the onboarding roster step. Creates the
+ * artist through the existing `POST /api/artists` endpoint and refreshes
+ * the shared roster (ArtistProvider) so the new artist appears in-flow.
+ */
+export function useAddRosterArtist() {
+ const { getAccessToken } = usePrivy();
+ const { getArtists } = useArtistProvider();
+ const { selectedOrgId } = useOrganization();
+ const [isAdding, setIsAdding] = useState(false);
+
+ const addArtist = async (name: string): Promise => {
+ const trimmed = name.trim();
+ if (!trimmed) return false;
+ setIsAdding(true);
+ try {
+ const accessToken = await getAccessToken();
+ if (!accessToken) {
+ throw new Error("Please sign in to add an artist");
+ }
+ await createRosterArtist(accessToken, trimmed, selectedOrgId);
+ await getArtists();
+ return true;
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to add artist",
+ );
+ return false;
+ } finally {
+ setIsAdding(false);
+ }
+ };
+
+ return { addArtist, isAdding };
+}
diff --git a/hooks/onboarding/useSocialFix.ts b/hooks/onboarding/useSocialFix.ts
new file mode 100644
index 000000000..f7865cdef
--- /dev/null
+++ b/hooks/onboarding/useSocialFix.ts
@@ -0,0 +1,56 @@
+"use client";
+
+import { useState } from "react";
+import { usePrivy } from "@privy-io/react-auth";
+import { toast } from "sonner";
+import saveArtist from "@/lib/saveArtist";
+import { buildSocialFixPayload } from "@/lib/onboarding/buildSocialFixPayload";
+import { useArtistProvider } from "@/providers/ArtistProvider";
+import type { ArtistRecord } from "@/types/Artist";
+
+/**
+ * Replaces a wrong auto-matched social (or adds a missing one) with a
+ * corrected profile URL via the existing PATCH /api/artists/{id}
+ * `profileUrls` path, then refreshes the roster. Matches are accepted by
+ * default, so there is no verdict to record — the refreshed roster is the
+ * source of truth for what's shown.
+ */
+export function useSocialFix() {
+ const { getAccessToken } = usePrivy();
+ const { getArtists } = useArtistProvider();
+ const [fixingArtistId, setFixingArtistId] = useState(null);
+
+ const fixSocial = async (
+ artist: ArtistRecord,
+ url: string,
+ ): Promise => {
+ const payload = buildSocialFixPayload(url);
+ if (!payload) {
+ toast.error(
+ "Paste a profile link from Spotify, Instagram, TikTok, YouTube, X, Facebook, Threads, or Apple Music",
+ );
+ return false;
+ }
+ setFixingArtistId(artist.account_id);
+ try {
+ const accessToken = await getAccessToken();
+ if (!accessToken) {
+ throw new Error("Please sign in to update socials");
+ }
+ await saveArtist(accessToken, artist.account_id, {
+ profileUrls: payload.profileUrls,
+ });
+ await getArtists();
+ return true;
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to update social",
+ );
+ return false;
+ } finally {
+ setFixingArtistId(null);
+ }
+ };
+
+ return { fixSocial, fixingArtistId };
+}
diff --git a/lib/artists/__tests__/createRosterArtist.test.ts b/lib/artists/__tests__/createRosterArtist.test.ts
new file mode 100644
index 000000000..50cbe4c0c
--- /dev/null
+++ b/lib/artists/__tests__/createRosterArtist.test.ts
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createRosterArtist } from "@/lib/artists/createRosterArtist";
+import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl";
+
+vi.mock("@/lib/api/getClientApiBaseUrl", () => ({
+ getClientApiBaseUrl: vi.fn(),
+}));
+
+describe("createRosterArtist", () => {
+ const accessToken = "test-token";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getClientApiBaseUrl).mockReturnValue(
+ "https://api.recoupable.com",
+ );
+ });
+
+ it("POSTs the artist name with bearer auth and returns the artist", async () => {
+ const artist = { id: "artist-1", account_id: "artist-1", name: "New Act" };
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({ artist }),
+ }) as unknown as typeof fetch;
+
+ const result = await createRosterArtist(accessToken, "New Act");
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ "https://api.recoupable.com/api/artists",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ Authorization: "Bearer test-token",
+ }),
+ body: JSON.stringify({ name: "New Act" }),
+ }),
+ );
+ expect(result).toEqual(artist);
+ });
+
+ it("includes organization_id when an org is provided", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({ artist: { id: "a" } }),
+ }) as unknown as typeof fetch;
+
+ await createRosterArtist(accessToken, "New Act", "org-1");
+
+ const body = JSON.parse(
+ vi.mocked(global.fetch).mock.calls[0][1]?.body as string,
+ );
+ expect(body).toEqual({ name: "New Act", organization_id: "org-1" });
+ });
+
+ it("throws the API error message on failure responses", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ json: vi
+ .fn()
+ .mockResolvedValue({ status: "error", error: "name is required" }),
+ }) as unknown as typeof fetch;
+
+ await expect(createRosterArtist(accessToken, "")).rejects.toThrow(
+ "name is required",
+ );
+ });
+
+ it("throws when the response has no artist", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({}),
+ }) as unknown as typeof fetch;
+
+ await expect(createRosterArtist(accessToken, "New Act")).rejects.toThrow(
+ "Failed to create artist",
+ );
+ });
+});
diff --git a/lib/artists/createRosterArtist.ts b/lib/artists/createRosterArtist.ts
new file mode 100644
index 000000000..d869ae844
--- /dev/null
+++ b/lib/artists/createRosterArtist.ts
@@ -0,0 +1,40 @@
+import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl";
+import type { ArtistRecord } from "@/types/Artist";
+
+interface CreateArtistResponse {
+ artist?: ArtistRecord;
+ error?: string;
+}
+
+/**
+ * Creates a new artist on the roster via the existing Recoup API
+ * `POST /api/artists` endpoint (Privy bearer auth — the owning account
+ * is inferred from the token). Mirrors `fetchArtists` / `createTask`.
+ */
+export async function createRosterArtist(
+ accessToken: string,
+ name: string,
+ orgId?: string | null,
+): Promise {
+ const body: Record = { name };
+ if (orgId) {
+ body.organization_id = orgId;
+ }
+
+ const response = await fetch(`${getClientApiBaseUrl()}/api/artists`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data: CreateArtistResponse = await response.json();
+
+ if (!response.ok || !data.artist) {
+ throw new Error(data.error || "Failed to create artist");
+ }
+
+ return data.artist;
+}
diff --git a/lib/onboarding/__tests__/buildSocialFixPayload.test.ts b/lib/onboarding/__tests__/buildSocialFixPayload.test.ts
new file mode 100644
index 000000000..7042d1e7d
--- /dev/null
+++ b/lib/onboarding/__tests__/buildSocialFixPayload.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { buildSocialFixPayload } from "@/lib/onboarding/buildSocialFixPayload";
+
+describe("buildSocialFixPayload", () => {
+ it("builds a PATCH profileUrls payload keyed by detected platform", () => {
+ expect(buildSocialFixPayload("https://instagram.com/laequis")).toEqual({
+ platform: "INSTAGRAM",
+ profileUrls: { INSTAGRAM: "https://instagram.com/laequis" },
+ });
+ });
+
+ it("detects platforms from bare domains without a scheme", () => {
+ expect(buildSocialFixPayload("tiktok.com/@laequis")).toEqual({
+ platform: "TIKTOK",
+ profileUrls: { TIKTOK: "tiktok.com/@laequis" },
+ });
+ });
+
+ it("normalizes the misspelled APPPLE platform key to APPLE for the API", () => {
+ const payload = buildSocialFixPayload(
+ "https://music.apple.com/us/artist/x/123",
+ );
+ expect(payload).toEqual({
+ platform: "APPLE",
+ profileUrls: { APPLE: "https://music.apple.com/us/artist/x/123" },
+ });
+ });
+
+ it("trims surrounding whitespace", () => {
+ expect(buildSocialFixPayload(" https://x.com/laequis ")).toEqual({
+ platform: "TWITTER",
+ profileUrls: { TWITTER: "https://x.com/laequis" },
+ });
+ });
+
+ it("returns null for an unrecognized platform", () => {
+ expect(buildSocialFixPayload("https://example.com/artist")).toBeNull();
+ });
+
+ it("returns null for empty input", () => {
+ expect(buildSocialFixPayload(" ")).toBeNull();
+ });
+});
diff --git a/lib/onboarding/__tests__/getSocialFollowerCount.test.ts b/lib/onboarding/__tests__/getSocialFollowerCount.test.ts
new file mode 100644
index 000000000..d9141bcd8
--- /dev/null
+++ b/lib/onboarding/__tests__/getSocialFollowerCount.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { getSocialFollowerCount } from "@/lib/onboarding/getSocialFollowerCount";
+
+describe("getSocialFollowerCount", () => {
+ it("reads the API's snake_case follower_count", () => {
+ expect(getSocialFollowerCount({ follower_count: 1200 })).toBe(1200);
+ });
+
+ it("falls back to the camelCase followerCount used by chat types", () => {
+ expect(getSocialFollowerCount({ followerCount: 88 })).toBe(88);
+ });
+
+ it("prefers snake_case when both are present", () => {
+ expect(
+ getSocialFollowerCount({ follower_count: 5, followerCount: 9 }),
+ ).toBe(5);
+ });
+
+ it("returns null when neither field is a number", () => {
+ expect(getSocialFollowerCount({})).toBeNull();
+ expect(getSocialFollowerCount({ follower_count: null })).toBeNull();
+ });
+});
diff --git a/lib/onboarding/buildSocialFixPayload.ts b/lib/onboarding/buildSocialFixPayload.ts
new file mode 100644
index 000000000..2bf82748d
--- /dev/null
+++ b/lib/onboarding/buildSocialFixPayload.ts
@@ -0,0 +1,24 @@
+import getSocialPlatformByLink from "@/lib/getSocialPlatformByLink";
+import { normalizeSocialPlatform } from "./normalizeSocialPlatform";
+
+export interface SocialFixPayload {
+ /** API platform key, e.g. "INSTAGRAM" (Apple normalized to "APPLE"). */
+ platform: string;
+ /** Body for the existing PATCH /api/artists/{id} `profileUrls` field. */
+ profileUrls: Record;
+}
+
+/**
+ * Builds the PATCH payload that replaces an artist's social for the
+ * platform detected from a corrected profile URL. Returns null when the
+ * URL is empty or doesn't map to a supported platform.
+ */
+export function buildSocialFixPayload(url: string): SocialFixPayload | null {
+ const trimmed = url.trim();
+ if (!trimmed) return null;
+
+ const platform = normalizeSocialPlatform(getSocialPlatformByLink(trimmed));
+ if (platform === "NONE") return null;
+
+ return { platform, profileUrls: { [platform]: trimmed } };
+}
diff --git a/lib/onboarding/getSocialFollowerCount.ts b/lib/onboarding/getSocialFollowerCount.ts
new file mode 100644
index 000000000..1cddda0d3
--- /dev/null
+++ b/lib/onboarding/getSocialFollowerCount.ts
@@ -0,0 +1,17 @@
+interface SocialFollowerFields {
+ follower_count?: number | null;
+ followerCount?: number | null;
+}
+
+/**
+ * The roster API spreads raw `socials` rows (snake_case `follower_count`)
+ * while chat's `SOCIAL` type declares camelCase `followerCount`. Read
+ * whichever is present so the UI survives both shapes.
+ */
+export function getSocialFollowerCount(
+ social: SocialFollowerFields,
+): number | null {
+ if (typeof social.follower_count === "number") return social.follower_count;
+ if (typeof social.followerCount === "number") return social.followerCount;
+ return null;
+}
diff --git a/lib/onboarding/normalizeSocialPlatform.ts b/lib/onboarding/normalizeSocialPlatform.ts
new file mode 100644
index 000000000..914436765
--- /dev/null
+++ b/lib/onboarding/normalizeSocialPlatform.ts
@@ -0,0 +1,9 @@
+/**
+ * Chat's legacy link detector spells Apple Music as "APPPLE" while the
+ * Recoup API expects "APPLE" (its own detector and the PATCH profileUrls
+ * keys both use the correct spelling). Normalize before comparing or
+ * sending platform keys to the API.
+ */
+export function normalizeSocialPlatform(platform: string): string {
+ return platform === "APPPLE" ? "APPLE" : platform;
+}