---
title: "Add students to a group"
method: POST
path: "/v2/groups/{id}/relationships/students/"
tags: ["Groups"]
---

# Add students to a group

`POST /v2/groups/{id}/relationships/students/`

Idempotently add students to the group.

Per JSON:API to-many relationship POST semantics, this is **idempotent**:
if the student is already a member, the existing membership is returned
as ``succeeded`` — there is no separate ``already_a_member`` error code.

Within-batch duplicate detection is first-wins on ``id``: the first
occurrence is processed; subsequent occurrences are pre-marked
``duplicate_in_batch`` with the JSON:API source pointer at ``/data/{i}/id``.

Returns 404 if the group is not in the caller's organization. The group
lookup runs **before** the envelope guard so a missing group is reported
as 404 regardless of body content — clients can't infer group existence
from the 400-vs-404 boundary.

**Required OAuth scope:** `student-groups:write`

## Path parameters

- `id` string, required

## Request body

- MembershipAddRequestEnvelope — JSON:API envelope for POST /v2/groups/{id}/relationships/students/. The ``data`` field is always an array — single-add is a list of one. Cap enforcement (<= DEFAULT_BATCH_MAX_ITEMS items) lives in the endpoint, NOT here — matches the create/update/delete contract (400 via ValidationError instead of Pydantic 422).
  - `data` MembershipStudentRefData[], required — List of students to add to the group.
    - `type` 'students', required — Must be "students".
    - `id` string, required — Obfuscated id of the student User row.

## Response `200`

OK

- BatchResultEnvelopeMembershipResource
  - `data` union[], required — Per-item results in request order.
    - union
      - BatchSucceededItemMembershipResource
        - `status` 'succeeded'
        - `id` string, nullable — Resource ID of the created or updated resource.
        - `result` MembershipResource, required — JSON:API resource object for a StudentGroupMembership row. Returned for every successful ``add`` row — newly created and idempotent re-adds alike (the existing row is returned in the same shape).
          - `type` 'memberships' — Always "memberships".
          - `id` string, required — Obfuscated id of the StudentGroupMembership row.
          - `attributes` MembershipAttributes, required — Attributes of a membership resource (returned for successful add rows). ``student_id`` is the obfuscated id of the student User row, useful for follow-up calls against /v2/students/. ``is_manager`` is False for newly-created rows (this endpoint never sets ``group_manager_assigned_at``); on idempotent re-adds it reflects the existing row's manager flag, so a re-add of a previously-promoted manager surfaces ``is_manager=true`` — promotion itself is a separate operation outside this endpoint's surface.
            - `student_id` string, required — Obfuscated id of the student User row.
            - `is_manager` boolean, required — True if the student is a manager for the group.
            - `created_at` string, date-time, required — Timestamp when the membership was created.
      - BatchFailedItem — Wrapper for a failed item in a 207 batch response.
        - `status` 'failed'
        - `id` string, nullable — Echoed resource ID if the input identified a row.
        - `error` BatchItemError, required — Per-item error inside a 207 batch response. Aligned with JSON:API ``ErrorObject`` field naming (``code`` rather than ``reason``) so consumers can reuse error-handling logic across document-level errors (``ErrorObject`` in ``ErrorEnvelope``) and per-item errors (here). Differences from ``ErrorObject``: ``status``/``title`` are omitted because they're redundant for the 207-batch context (HTTP status is on the envelope, and the title is derivable from ``code``).
          - `code` 'duplicate_email' | 'duplicate_in_batch' | 'duplicate_name' | 'validation_error' | 'not_found' | 'internal_error' | 'not_in_domain' | 'already_enrolled' | 'already_published', required — Machine-readable error code.
          - `source` object, nullable — Pointer to the offending input slot, e.g. {"pointer": "/data/0/attributes/email"}.
          - `detail` string, nullable — Human-readable explanation of the error.
  - `summary` BatchSummary, required — Aggregate counts for a 207 batch response. Invariant: ``succeeded + failed == total``. Enforced by ``@model_validator``.
    - `total` integer, required — Total number of items submitted.
    - `succeeded` integer, required — Number of items that succeeded.
    - `failed` integer, required — Number of items that failed.

## Other responses

- `207` — Multi-Status
- `400` — Bad Request
- `401` — Unauthorized
- `403` — Forbidden
- `404` — Not Found
- `422` — Unprocessable Entity

---

[API](https://skmtc.net/skilljar/apis/skilljar-api-v2.md) · [All operations](https://skmtc.net/skilljar/apis/skilljar-api-v2/llms.txt) · [OpenAPI document](https://skmtc-service-staging.skmtc.workers.dev/v1/apis/skilljar/skilljar-api-v2/revisions/f8025de20169/schema)
