> ## Documentation Index
> Fetch the complete documentation index at: https://developer.vanta.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Assign a control owner

> Make a specific Vanta user accountable for a framework control's compliance status, using the Manage Vanta API.

export function vibePromptBuildText(prompt, pageTitle) {
  const url = typeof window !== "undefined" && window.location ? window.location.href : "";
  const ref = url ? "\n\nReference: " + (pageTitle || "Vanta docs") + " — " + url : "";
  return prompt + ref;
}

export function vibePromptOpen(e) {
  e.preventDefault();
  e.stopPropagation();
  const link = e.currentTarget;
  const target = link.getAttribute("data-target");
  const prompt = link.getAttribute("data-prompt") || "";
  const pageTitle = link.getAttribute("data-page-title") || "";
  const URL_LIMIT = 8000;
  const text = vibePromptBuildText(prompt, pageTitle);
  let enc = encodeURIComponent(text);
  if (enc.length > URL_LIMIT) enc = encodeURIComponent(text.slice(0, 2400));
  let dest = "";
  if (target === "cursor") dest = "cursor://anysphere.cursor-deeplink/prompt?text=" + enc; else if (target === "claude") dest = "https://claude.ai/new?q=" + enc; else if (target === "chatgpt") dest = "https://chatgpt.com/?q=" + enc;
  if (!dest) return;
  if (target === "cursor") {
    window.location.href = dest;
  } else {
    window.open(dest, "_blank", "noopener,noreferrer");
  }
}

export function vibePromptCopy(e) {
  e.preventDefault();
  e.stopPropagation();
  const btn = e.currentTarget;
  const prompt = btn.getAttribute("data-prompt") || "";
  const pageTitle = btn.getAttribute("data-page-title") || "";
  const original = btn.getAttribute("data-label") || btn.innerText;
  btn.setAttribute("data-label", original);
  const text = vibePromptBuildText(prompt, pageTitle);
  navigator.clipboard.writeText(text).then(function () {
    btn.innerText = "Copied";
    setTimeout(function () {
      btn.innerText = original;
    }, 1500);
  }, function () {
    btn.innerText = "Copy failed";
    setTimeout(function () {
      btn.innerText = original;
    }, 1500);
  });
}

export function vibePromptToggle(e) {
  e.preventDefault();
  const summary = e.currentTarget;
  const panel = summary.parentElement;
  if (!panel) return;
  const body = panel.querySelector(".vibe-prompt__body");
  const chevron = summary.querySelector(".vibe-prompt__chevron");
  const isOpen = panel.getAttribute("data-open") === "true";
  const next = isOpen ? "false" : "true";
  panel.setAttribute("data-open", next);
  summary.setAttribute("aria-expanded", next);
  if (body) body.style.display = isOpen ? "none" : "block";
  if (chevron) chevron.style.transform = isOpen ? "rotate(0deg)" : "rotate(180deg)";
}

export const BuildPrompt = ({prompt, pageTitle, defaultOpen}) => <div className="vibe-prompt__panel" data-open={defaultOpen ? "true" : "false"} style={{
  marginTop: "0.85rem",
  borderRadius: "12px",
  border: "1px solid var(--vanta-border, rgba(120, 120, 130, 0.18))",
  background: "color-mix(in srgb, #5E05C4 4%, transparent)",
  overflow: "hidden"
}}>
    <button type="button" onClick={vibePromptToggle} aria-expanded={defaultOpen ? "true" : "false"} style={{
  display: "flex",
  alignItems: "center",
  gap: "0.75rem",
  width: "100%",
  padding: "0.9rem 1.1rem",
  background: "transparent",
  border: "none",
  textAlign: "left",
  cursor: "pointer",
  font: "inherit",
  color: "inherit"
}}>
      <span style={{
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
  gap: "0.35rem",
  padding: "0.2rem 0.55rem",
  borderRadius: "9999px",
  fontSize: "0.7rem",
  fontWeight: 700,
  letterSpacing: "0.06em",
  textTransform: "uppercase",
  color: "#5E05C4",
  background: "color-mix(in srgb, #5E05C4 12%, transparent)",
  border: "1px solid color-mix(in srgb, #5E05C4 30%, transparent)",
  whiteSpace: "nowrap",
  flexShrink: 0,
  minWidth: "9rem"
}}>
        Code this
      </span>
      <span style={{
  flex: 1,
  minWidth: 0
}}>
        <span className="text-gray-600 dark:text-gray-300" style={{
  display: "block",
  fontSize: "0.85rem",
  lineHeight: 1.45
}}>
          Generate a script or app that performs this function using the Vanta API.
        </span>
      </span>
      <span className="vibe-prompt__chevron" aria-hidden="true" style={{
  flexShrink: 0,
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
  width: "24px",
  height: "24px",
  color: "#5E05C4",
  transform: defaultOpen ? "rotate(180deg)" : "rotate(0deg)",
  transition: "transform 200ms ease"
}}>
        <Icon icon="chevron-down" iconType="regular" size={14} color="#5E05C4" />
      </span>
    </button>
    <div className="vibe-prompt__body" style={{
  display: defaultOpen ? "block" : "none",
  padding: "0 1.1rem 1.1rem"
}}>
      <div style={{
  margin: 0,
  padding: "0.85rem 1rem",
  borderRadius: "8px",
  background: "rgba(15, 17, 21, 0.92)",
  color: "#f4f4f5",
  fontSize: "0.78rem",
  lineHeight: 1.55,
  fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
  whiteSpace: "pre-wrap",
  wordBreak: "break-word",
  overflowWrap: "anywhere",
  overflowX: "hidden",
  overflowY: "auto",
  maxHeight: "22rem"
}}>
        {prompt}
      </div>
      <div className="not-prose" style={{
  display: "flex",
  flexWrap: "wrap",
  gap: "0.5rem",
  marginTop: "0.75rem"
}}>
        <button type="button" onClick={vibePromptCopy} data-prompt={prompt} data-page-title={pageTitle} style={{
  display: "inline-flex",
  alignItems: "center",
  gap: "0.4rem",
  padding: "0.4rem 0.85rem",
  borderRadius: "8px",
  border: "none",
  cursor: "pointer",
  fontSize: "0.8rem",
  fontWeight: 600,
  color: "#ffffff",
  background: "#5E05C4"
}}>
          <Icon icon="copy" iconType="regular" size={13} color="#ffffff" />
          Copy prompt
        </button>
        <a href="#" onClick={vibePromptOpen} data-target="cursor" data-prompt={prompt} data-page-title={pageTitle} className="vibe-prompt__link" style={{
  display: "inline-flex",
  alignItems: "center",
  gap: "0.4rem",
  padding: "0.4rem 0.75rem",
  borderRadius: "8px",
  border: "1px solid var(--vanta-border, rgba(120, 120, 130, 0.25))",
  fontSize: "0.8rem",
  fontWeight: 500,
  textDecoration: "none",
  color: "inherit",
  background: "transparent"
}}>
          <Icon icon="arrow-up-right-from-square" iconType="regular" size={11} color="#5E05C4" /><span>Cursor</span>
        </a>
        <a href="#" onClick={vibePromptOpen} data-target="claude" data-prompt={prompt} data-page-title={pageTitle} className="vibe-prompt__link" style={{
  display: "inline-flex",
  alignItems: "center",
  gap: "0.4rem",
  padding: "0.4rem 0.75rem",
  borderRadius: "8px",
  border: "1px solid var(--vanta-border, rgba(120, 120, 130, 0.25))",
  fontSize: "0.8rem",
  fontWeight: 500,
  textDecoration: "none",
  color: "inherit",
  background: "transparent"
}}>
          <Icon icon="arrow-up-right-from-square" iconType="regular" size={11} color="#5E05C4" /><span>Claude</span>
        </a>
        <a href="#" onClick={vibePromptOpen} data-target="chatgpt" data-prompt={prompt} data-page-title={pageTitle} className="vibe-prompt__link" style={{
  display: "inline-flex",
  alignItems: "center",
  gap: "0.4rem",
  padding: "0.4rem 0.75rem",
  borderRadius: "8px",
  border: "1px solid var(--vanta-border, rgba(120, 120, 130, 0.25))",
  fontSize: "0.8rem",
  fontWeight: 500,
  textDecoration: "none",
  color: "inherit",
  background: "transparent"
}}>
          <Icon icon="arrow-up-right-from-square" iconType="regular" size={11} color="#5E05C4" /><span>ChatGPT</span>
        </a>
      </div>
    </div>
  </div>;

export const VibePrompts = ({children}) => <section className="vibe-prompts" style={{
  marginTop: "2.5rem",
  marginBottom: "2rem",
  padding: "1.5rem 1.5rem 1.25rem",
  borderRadius: "16px",
  border: "1px solid color-mix(in srgb, #5E05C4 22%, transparent)",
  background: "linear-gradient(180deg, color-mix(in srgb, #5E05C4 6%, transparent) 0%, transparent 100%)"
}}>
    <div style={{
  display: "flex",
  alignItems: "center",
  gap: "0.65rem",
  marginBottom: "0.25rem"
}}>
      <div style={{
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
  width: "32px",
  height: "32px",
  borderRadius: "9px",
  background: "color-mix(in srgb, #5E05C4 14%, transparent)"
}}>
        <Icon icon="wand-magic-sparkles" iconType="regular" size={16} color="#5E05C4" />
      </div>
      <div>
        <div className="text-gray-900 dark:text-white" style={{
  fontFamily: "Reckless, Georgia, serif",
  fontSize: "1.25rem",
  fontWeight: 500,
  lineHeight: 1.15
}}>
          Let AI do this for you
        </div>
        <div className="text-gray-600 dark:text-gray-300" style={{
  fontSize: "0.85rem",
  lineHeight: 1.45,
  marginTop: "0.15rem"
}}>
          Copy the prompt to run this live via the <a href="/docs/vanta-mcp" style={{
  color: "#5E05C4"
}}> MCP server</a> or have AI generate a runnable script.
        </div>
      </div>
    </div>
    {children}
  </section>;

export const CONTROL_OWNER_BUILD = "You are writing a production-quality Node.js 18+ script that runs on a weekly schedule (cron / GitHub Actions / Cloud Scheduler) and keeps every Vanta framework control assigned to an active owner. Use the Manage Vanta API (base URL https://api.vanta.com).\n\nSteps:\n\n1. Read VANTA_CLIENT_ID, VANTA_CLIENT_SECRET, and DEFAULT_OWNER_EMAIL from env. Also accept STATE_PATH (default ./.vanta-control-owners-state.json) and DRY_RUN (default \"false\"). Fail fast on stderr if any required var is missing — do not hard-code an email or user id in the script.\n2. Mint a Vanta API token via POST https://api.vanta.com/oauth/token with Content-Type: application/json and a JSON body containing client_id, client_secret, grant_type=\"client_credentials\", and scope=\"vanta-api.all:read vanta-api.all:write\". The scope field is required — omitting it returns invalid_scope. Read access_token from the response. Re-mint at the start of each run (tokens expire after one hour).\n3. Resolve DEFAULT_OWNER_EMAIL to a user id. Page through GET https://api.vanta.com/v1/people?employmentStatusMatchesAny=CURRENT&pageSize=100 using the pageCursor request param (read results.pageInfo.endCursor and hasNextPage until hasNextPage is false). Match emailAddress case-insensitively and capture the id as DEFAULT_OWNER_ID. Exit non-zero with a clear message if not found, or if employment.status is not CURRENT.\n4. Build a one-shot lookup of FORMER user ids by paging GET https://api.vanta.com/v1/people?employmentStatusMatchesAny=FORMER&pageSize=100. Store as a Set of person ids.\n5. Page through GET https://api.vanta.com/v1/controls?pageSize=100 using pageCursor, collecting every control. A control is a candidate for reassignment when ANY of the following is true (evaluate each clause independently):\n   (a) owner is null,\n   (b) owner.id is in the FORMER set, or\n   (c) owner.id is not found in either the CURRENT or FORMER set (orphaned reference).\n   Controls already owned by DEFAULT_OWNER_ID are not candidates — skip them.\n6. Load STATE_PATH if present — { \"alreadyReassigned\": [controlId, ...] } — and skip those controls.\n7. Print the dry-run plan: one line per candidate as controlId | name | domains[] | previous owner displayName | previous owner status | new owner displayName.\n8. If DRY_RUN === \"true\", print a JSON summary line and exit 0 without writing.\n9. Otherwise, for each remaining candidate POST https://api.vanta.com/v1/controls/{controlId}/set-owner with body { \"userId\": \"<DEFAULT_OWNER_ID>\" }. Issue requests sequentially to keep ordering deterministic and stay under rate limits. Use the control id (e.g. \"database-replication-utilized\"), never externalId (e.g. \"BCD-4\").\n10. Append every successfully reassigned controlId to alreadyReassigned and write STATE_PATH atomically (temp file + rename).\n11. End with a single JSON summary line on stdout: {\"total\": N, \"reassigned\": R, \"skipped\": S, \"errors\": E, \"byDomain\": {...}}.\n\nSample API response item from GET /v1/controls (use these field names exactly):\n{ \"id\": \"database-replication-utilized\", \"externalId\": \"BCD-4\", \"name\": \"Database replication utilized\", \"description\": \"...\", \"source\": \"Vanta\", \"domains\": [\"BUSINESS_CONTINUITY_&_DISASTER_RECOVERY\"], \"owner\": { \"id\": \"5fc82421a228f6b6f713547d\", \"emailAddress\": \"admin@example.com\", \"displayName\": \"Admin Admin\" }, \"role\": \"CONTROLLER\", \"customFields\": [], \"creationDate\": null, \"modificationDate\": null }\n\nSample API response from POST /v1/controls/{controlId}/set-owner (200): the full control object with the new owner populated.\n\nMissing-field handling: if owner is null, treat as \"(unassigned)\" in logs. If domains is empty or missing, print \"—\". Never throw on a missing optional field.\n\nError handling:\n- 401: re-mint once and retry the request.\n- 404 from set-owner: log controlId + the response body, mark this control as ERROR, continue.\n- 4xx on the userId (FORMER user, etc.): log and continue — do not retry with a different user.\n- 429: respect Retry-After header if present, otherwise sleep 5s; retry up to 3 times.\n- 5xx: retry with 2s exponential back-off, max 3 attempts; then log and exit non-zero only if every batch failed.\n\nDo not:\n- Add external dependencies — use built-in fetch, fs, and path only.\n- Hard-code any control id, framework name, domain, owner email, or user id — every customer's control set is different. Read DEFAULT_OWNER_EMAIL from env and discover everything else via the API.\n- Use externalId when calling set-owner — always use id.\n- Apply changes when DRY_RUN is \"true\".\n- Cache the access token across runs.\n- Silently swallow errors — every caught error must log a clear message.\n\nDone when: a follow-up run reports 0 candidates (every control has a CURRENT owner). Exit 0 only if errors === 0; otherwise exit 1.\n\nScope required: vanta-api.all:read vanta-api.all:write.";

This guide assigns a Vanta user as the owner of a framework control with `POST /v1/controls/{controlId}/set-owner` on the Manage Vanta API. Use the build prompt below to generate a scheduled job that keeps every control attached to a current employee — reassigning controls whose owner is null, departed, or pointing at an orphaned user id.

<VibePrompts>
  <BuildPrompt prompt={CONTROL_OWNER_BUILD} />
</VibePrompts>

## Before you begin

This guide is for Vanta admins managing data inside their own Vanta account.

You'll need:

* A Manage Vanta [API token](/docs/quickstart/manage-vanta).
* The token must have scopes `vanta-api.all:read` and `vanta-api.all:write`.
* The user you're assigning must already exist in Vanta.

<Info>
  Reassigning many controls at once (e.g. onboarding a new compliance lead)? Skip ahead to [Bulk-assign owners](#bulk-assign-owners).
</Info>

<Steps>
  <Step title="Find the control">
    **Your terminal** — call [`GET /v1/controls`](/reference/manage-vanta/overview) and pick the control you want to assign.

    ```bash Terminal theme={"system"}
    curl 'https://api.vanta.com/v1/controls?pageSize=100&frameworkMatchesAny=soc2' \
      -H 'Accept: application/json' \
      -H 'Authorization: Bearer YOUR_TOKEN'
    ```

    Response

    ```json theme={"system"}
    {
      "results": {
        "data": [
          {
            "id": "database-replication-utilized",
            "externalId": "BCD-4",
            "name": "Database replication utilized",
            "owner": { "id": "5fc82421a228f6b6f713547d", "displayName": "Admin Admin" }
          }
        ],
        "pageInfo": { "hasNextPage": true, "endCursor": "..." }
      }
    }
    ```

    Copy the `id` field — **not** `externalId`.

    <AccordionGroup>
      <Accordion title="Got a 401?">
        Token is expired (one-hour lifetime), missing, or lacks `vanta-api.all:read`. Mint a fresh one — see [Authentication → Token expiration](/docs/concepts/authentication#token-expiration).
      </Accordion>

      <Accordion title="Can't find your control?">
        Filter the response client-side by `name` or `externalId`, paginate with `pageCursor` if `hasNextPage` is `true`, or copy the ID directly from the [Controls page](https://app.vanta.com/controls) URL.
      </Accordion>
    </AccordionGroup>
  </Step>

  <Step title="Get a user">
    **Your terminal** — call [`GET /v1/people`](/reference/manage-vanta/overview) to find the user you want to assign as owner. Filter to `CURRENT` employees so you don't pick someone who's offboarded.

    ```bash Terminal theme={"system"}
    curl 'https://api.vanta.com/v1/people?pageSize=100&employmentStatusMatchesAny=CURRENT' \
      -H 'Accept: application/json' \
      -H 'Authorization: Bearer YOUR_TOKEN'
    ```

    Response

    ```json theme={"system"}
    {
      "results": {
        "data": [
          {
            "id": "611fd13785dbc71bb89fd401",
            "emailAddress": "developers@vanta.com",
            "name": { "display": "Alejandro Ocampo" },
            "employment": { "status": "CURRENT", "jobTitle": "Security Engineer" }
          }
        ]
      }
    }
    ```

    Copy the `id`. You'll send it as `userId` in the next step. Confirm `employment.status` is `CURRENT` — `FORMER` users will be rejected by `set-owner`.

    <AccordionGroup>
      <Accordion title="Can't find the user?">
        Filter client-side by `emailAddress` (most reliable), or paginate with `pageCursor` if `hasNextPage` is `true`. If they're missing entirely, they may not be provisioned in Vanta yet — check the [People page](https://app.vanta.com/people/people).
      </Accordion>

      <Accordion title="They show as `FORMER`?">
        They've been offboarded and aren't eligible to own controls. Pick a `CURRENT` user instead.
      </Accordion>
    </AccordionGroup>
  </Step>

  <Step title="Assign the owner">
    **Your terminal** — `POST /v1/controls/{controlId}/set-owner` with the user ID in the body.

    <CodeGroup>
      ```javascript Node.js theme={"system"}
      const CONTROL_ID = "YOUR_CONTROL_ID"; 
      const USER_ID = "YOUR_USER_ID";     

      const res = await fetch(
        `https://api.vanta.com/v1/controls/${CONTROL_ID}/set-owner`,
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: "Bearer YOUR_TOKEN",
          },
          body: JSON.stringify({ userId: USER_ID }),
        },
      );
      console.log(await res.json());
      ```

      ```python Python theme={"system"}
      import requests

      CONTROL_ID = "YOUR_CONTROL_ID"  # id from Step 1
      USER_ID = "YOUR_USER_ID"        # id from Step 2

      r = requests.post(
          f"https://api.vanta.com/v1/controls/{CONTROL_ID}/set-owner",
          headers={"Authorization": "Bearer YOUR_TOKEN"},
          json={"userId": USER_ID},
      )
      r.raise_for_status()
      print(r.json())
      ```

      ```bash Terminal theme={"system"}
      CONTROL_ID="YOUR_CONTROL_ID"  # id from Step 1
      USER_ID="YOUR_USER_ID"        # id from Step 2

      curl -X POST "https://api.vanta.com/v1/controls/$CONTROL_ID/set-owner" \
        -H 'Content-Type: application/json' \
        -H 'Accept: application/json' \
        -H 'Authorization: Bearer YOUR_TOKEN' \
        -d "{\"userId\": \"$USER_ID\"}"
      ```
    </CodeGroup>

    Expected response (`200`) — the full control object with the new owner:

    ```json theme={"system"}
    {
      "id": "database-replication-utilized",
      "externalId": "BCD-4",
      "name": "Database replication utilized",
      "owner": {
        "id": "611fd13785dbc71bb89fd401",
        "emailAddress": "developers@vanta.com",
        "displayName": "Alejandro Ocampo"
      },
      "domains": ["BUSINESS_CONTINUITY_&_DISASTER_RECOVERY"]
    }
    ```

    <Warning>
      `set-owner` overwrites any existing owner.
    </Warning>

    <AccordionGroup>
      <Accordion title="Got a 404?">
        The control ID is wrong. Most often this is because you copied `externalId` (e.g. `BCD-4`) instead of `id` (e.g. `database-replication-utilized`). Re-run Step 1 and copy `id`.
      </Accordion>

      <Accordion title="Got a 4xx on the userId?">
        The `userId` is invalid or the user is ineligible — typically because their `employment.status` is `FORMER`. Re-fetch with `employmentStatusMatchesAny=CURRENT` and pick a different user.
      </Accordion>

      <Accordion title="Got a 403?">
        Your token has `vanta-api.all:read` but not `vanta-api.all:write`. Mint a token with both scopes.
      </Accordion>
    </AccordionGroup>
  </Step>
</Steps>

<Info>
  **Unassign with `null`.** Pass `{ "userId": null }` to remove the owner without replacing them.
</Info>

## Congratulations

You've assigned a Vanta user as the accountable owner of a framework control. That user will now receive notifications and reminders for the control, and the change is captured in your audit log.

## Next steps

<CardGroup cols={2}>
  <Card title="Offboard people" icon="user-minus" href="/docs/guides/offboard-people">
    Reassign every control owned by a departing teammate before you remove them.
  </Card>

  <Card title="Subscribe to webhooks" icon="bolt" href="/docs/webhooks">
    React in real time when ownership or control status changes.
  </Card>

  <Card title="Try it in Postman" icon="paper-plane" href="/docs/postman-setup">
    Import the collection and run `set-owner` against a sandbox in seconds.
  </Card>

  <Card title="Manage Vanta API reference" icon="book" href="/reference/manage-vanta/overview">
    Browse every Manage Vanta endpoint — controls, tests, documents, people.
  </Card>
</CardGroup>
