{
  "openapi": "3.1.0",
  "info": {
    "title": "Digital Skills Assessment API",
    "version": "1.0.0",
    "description": "Public REST API for e-portfolio and MIS platforms (Aptem, OneFile, Maytas, Bud, PICS, Smart Assessor and others) to consume assessment results from Digital Skills Assessment.\n\n**Integration shape**: your platform is the API client. An organisation admin generates an Access Key ID and Secret Access Key in DSA (Organisation Settings), and pastes them into your platform's integration configuration — the same shape used by the incumbent assessment APIs this market already integrates with. No OAuth2.\n\n**Polling**: results are designed for cheap, frequent, incremental polling (per-minute is fine). Use `since`/`cursor` pagination and `If-None-Match` to receive `304 Not Modified` on unchanged polls. Rate limits are per key: 300 requests/minute, with `429` + `Retry-After` when exceeded.\n\n**Funding fields**: `rqf_level` places the learner's current skills level within the RQF level descriptors (ASF funding rules 2026/27 §2.5) and `below_level_1` evidences the digital entitlement eligibility gate. Both are first-class response fields.\n\n**PII**: National Insurance numbers are not stored and never returned. Filter by opaque IDs and your own learner references only — no PII in URLs.\n\n**Sandbox**: a sandbox environment with seeded fake learners and results is available on request — contact admin@digitalskillsassessment.co.uk.\n\n**Filtering (three rules)**: CSV within a param is OR - different params are AND - unknown params are rejected with 400. Repeated keys (`subject=maths&subject=english`) are identical to CSV. All filter params are flat snake_case — no `filter[x]` brackets, no operator suffixes.\n\n**Delta sync correctness**: `subject`, `assessed_from`/`assessed_to`, `rqf_level_code` and `below_level_1` are DELTA-SAFE (immutable once a result is complete) — combine freely with `since`. `group_id` and `status` are DELTA-UNSAFE (mutable): a record can stop matching them, and absence is indistinguishable from \"nothing changed\". Mitigations: group membership changes cascade `updated_at` (the record reappears in the unfiltered feed and the new group's feed), and `group_id`-filtered polls with `since` receive scope-exit tombstones (`removed: {reason: \"left_scope\", scope: \"group_id\"}`) for assessments whose learner left the group. **Run a weekly unfiltered reconciliation sweep as the authoritative sync** — filtered delta feeds are a convenience, not the source of truth."
  },
  "servers": [
    {
      "url": "https://www.digitalskillsassessment.co.uk/api/v1"
    }
  ],
  "security": [
    {
      "accessKeyId": [],
      "secretAccessKey": []
    }
  ],
  "components": {
    "securitySchemes": {
      "accessKeyId": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Access-Key-Id"
      },
      "secretAccessKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Secret-Access-Key"
      }
    },
    "schemas": {
      "Assessment": {
        "type": "object",
        "properties": {
          "assessment_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "completed",
              "deleted"
            ],
            "description": "completed = live result. deleted = tombstone: the record was withdrawn; remove it from your system. Tombstones carry only assessment_id, status and updated_at."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Sync position. Changes whenever the record is amended or deleted — poll with since= against this field."
          },
          "learner": {
            "type": "object",
            "properties": {
              "ref": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The provider's own learner reference"
              },
              "full_name": {
                "type": "string"
              }
            }
          },
          "assessed_at": {
            "type": "string",
            "format": "date-time"
          },
          "subject": {
            "type": "string",
            "enum": [
              "digital",
              "english",
              "maths"
            ]
          },
          "framework": {
            "type": "string",
            "examples": [
              "Essential Digital Skills Standards 2026"
            ]
          },
          "rqf_level": {
            "type": [
              "string",
              "null"
            ],
            "description": "Placement within the RQF level descriptors (ASF §2.5)"
          },
          "rqf_level_code": {
            "type": [
              "integer",
              "null"
            ],
            "description": "ILR PriorLevel code semantics: Entry Level=1, Level 1=2, Level 2=3, Level 3=5"
          },
          "below_level_1": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "ASF digital entitlement eligibility gate"
          },
          "working_level": {
            "type": [
              "string",
              "null"
            ]
          },
          "recommended_next_level": {
            "type": [
              "string",
              "null"
            ],
            "description": "Supports the ASF enrol-above-assessed-level rule"
          },
          "is_borderline": {
            "type": "boolean",
            "description": "The placement sits close to a level boundary: a 95% confidence interval crossed the deciding rule's threshold. Review alongside wider evidence before funding decisions."
          },
          "needs_review": {
            "type": "boolean",
            "description": "The two scoring engines (rule-based and IRT) placed this result a full level or more apart; the reconciled level was used. Human review recommended."
          },
          "domains": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "score_pct": {
                  "type": [
                    "integer",
                    "null"
                  ]
                },
                "status": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          },
          "evidence_pack_url": {
            "type": "string",
            "description": "The evidence PDF for this assessment"
          },
          "report_sha256": {
            "type": [
              "string",
              "null"
            ],
            "description": "SHA-256 of the evidence PDF, stored at first generation"
          },
          "groups": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/GroupSummary"
            },
            "description": "The learner's active groups. Poll unfiltered and fan out client-side on these — most integrations never need group_id filtering at all."
          }
        }
      },
      "Pagination": {
        "type": "object",
        "properties": {
          "limit": {
            "type": "integer"
          },
          "has_more": {
            "type": "boolean"
          },
          "next": {
            "type": [
              "string",
              "null"
            ],
            "description": "Absolute URL of the next page; null when exhausted"
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string"
              },
              "message": {
                "type": "string"
              }
            }
          }
        }
      },
      "Tombstone": {
        "type": "object",
        "description": "A soft-deleted assessment. Appears in the incremental feed so syncing consumers can withdraw the record; carries no learner data or scores.",
        "properties": {
          "assessment_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "deleted"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "GroupSummary": {
        "type": "object",
        "description": "A group the learner actively belongs to, embedded in Assessment and Learner payloads. `type` is an open enum: new values may be added — do not fail on unknown ones.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "ref": {
            "type": "string",
            "description": "Your own group reference (course code, cohort label)"
          },
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "examples": [
              "cohort",
              "course",
              "class",
              "curriculum_area",
              "campus",
              "funding_stream",
              "employer",
              "caseload",
              "team"
            ]
          }
        }
      },
      "Group": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "ref": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "description": "Open enum — clients must tolerate unknown values. `team` groups are managed automatically from the organisation's teams and cannot be created or modified via the API."
          },
          "active": {
            "type": "boolean"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ScopeExitTombstone": {
        "type": "object",
        "description": "Emitted on group_id-filtered polls with since=: the learner left the requested group, so this assessment no longer matches the filter. Remove it from your copy of that group. Repeats across polls are possible — key on assessment_id.",
        "properties": {
          "assessment_id": {
            "type": "string",
            "format": "uuid"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "removed": {
            "type": "object",
            "properties": {
              "reason": {
                "type": "string",
                "enum": [
                  "left_scope"
                ]
              },
              "scope": {
                "type": "string",
                "enum": [
                  "group_id"
                ]
              }
            }
          }
        }
      }
    }
  },
  "paths": {
    "/health": {
      "get": {
        "summary": "Connectivity test",
        "description": "Verifies credentials. Point your platform's 'Test Connection' button here.",
        "responses": {
          "200": {
            "description": "Credentials valid",
            "content": {
              "application/json": {
                "example": {
                  "status": "ok",
                  "organisation_id": "…",
                  "api_version": "v1"
                }
              }
            }
          },
          "401": {
            "description": "Invalid credentials",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/assessments": {
      "get": {
        "summary": "Incremental assessment results (the critical endpoint)",
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Return results whose updated_at is at or after this position. Amended results are re-fetched; deletions arrive as tombstones (status \"deleted\")."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Opaque cursor from the previous page's pagination.next. Cursors are bound to the filter set they were minted under — reusing one with different filters returns 400."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 500
            }
          },
          {
            "name": "learner_ref",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter to one learner by your own opaque reference (letters, digits, . _ : / -; max 100 chars). Never pass email addresses or names — PII in query strings is rejected with 400."
          },
          {
            "name": "subject",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "CSV of subjects (OR): digital, english, maths. Delta-safe."
          },
          {
            "name": "assessed_from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Results assessed at or after this date. Delta-safe."
          },
          {
            "name": "assessed_to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Results assessed at or before this date. Delta-safe."
          },
          {
            "name": "rqf_level_code",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "CSV of ILR PriorLevel codes (OR): 1 (Entry), 2 (Level 1), 3 (Level 2), 5 (Level 3). No comparison operators — `rqf_level_code=1,2` expresses 'at or below Level 1' completely. Delta-safe. Prefer below_level_1=true for the ASF entitlement gate."
          },
          {
            "name": "below_level_1",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ]
            },
            "description": "The ASF digital entitlement eligibility gate, pre-derived — lead with this rather than re-deriving from level codes. Delta-safe."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "CSV (OR): completed, deleted. DELTA-UNSAFE — see the delta sync correctness notes."
          },
          {
            "name": "group_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "CSV of group UUIDs (OR) from /groups; max 20. Matches assessments of the groups' ACTIVE members. DELTA-UNSAFE: with since=, scope-exit tombstones are emitted for learners who left the group — process them, and run the weekly unfiltered reconciliation sweep."
          },
          {
            "name": "sort",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "updated_at",
                "-updated_at",
                "assessed_at",
                "-assessed_at"
              ]
            },
            "description": "Whitelist of four values; '-' prefix for descending (never '+', which decodes to a space). Rejected together with since= — delta feeds are ordered by updated_at by definition."
          }
        ],
        "responses": {
          "200": {
            "description": "Page of results (ETag supplied for If-None-Match polling)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "oneOf": [
                          {
                            "$ref": "#/components/schemas/Assessment"
                          },
                          {
                            "$ref": "#/components/schemas/Tombstone"
                          },
                          {
                            "$ref": "#/components/schemas/ScopeExitTombstone"
                          }
                        ]
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                }
              }
            }
          },
          "304": {
            "description": "Unchanged since the supplied If-None-Match ETag"
          },
          "429": {
            "description": "Rate limited — honour Retry-After",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "400": {
            "description": "Invalid or unknown parameter (unknown_parameter, invalid_since, invalid_cursor, invalid_learner_ref, invalid_subject, invalid_assessed_from, invalid_assessed_to, invalid_rqf_level_code, invalid_below_level_1, invalid_status, invalid_group_id, too_many_groups, invalid_sort, sort_with_since)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "description": "Unknown query parameters are rejected with 400 `unknown_parameter` — a misspelt filter never silently returns the full result set. Filter grammar: CSV within a param is OR, different params are AND. See the top-level description for the delta-safe/delta-unsafe split and the weekly reconciliation requirement."
      }
    },
    "/assessments/{id}": {
      "get": {
        "summary": "Single assessment with domain breakdown",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The assessment",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Assessment"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Not found (also returned for resources belonging to another organisation)"
          }
        }
      }
    },
    "/assessments/{id}/report.pdf": {
      "get": {
        "summary": "The evidence artefact (PDF)",
        "description": "Deterministic evidence PDF. X-Report-SHA256 carries the integrity hash stored at first generation.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF binary",
            "content": {
              "application/pdf": {}
            }
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/learners": {
      "get": {
        "summary": "Incremental learner list",
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Distinct learners with last-assessed positions and their active groups (groups[])"
          },
          "400": {
            "description": "Invalid or unknown parameter (unknown_parameter, invalid_since, invalid_cursor)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "description": "Unknown query parameters are rejected with 400 `unknown_parameter`; cursors are bound to the query they were minted under."
      },
      "post": {
        "summary": "Provision a learner into DSA",
        "description": "Creates an assessment invitation for the learner using the organisation's default test type. Consumes credits exactly as dashboard invitations do; education/unlimited plans bypass credits. The optional `groups` array is upserted by ref on (type, ref) — no group_id lookup round-trip needed. Missing groups are auto-created (unless the organisation has disabled auto-creation), capped at 500 groups per organisation.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "full_name"
                ],
                "properties": {
                  "full_name": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "ref": {
                    "type": "string",
                    "description": "Your platform's learner reference — opaque (letters, digits, . _ : / -; max 100 chars). Email addresses and names are rejected: this value later appears in learner_ref filters and /learners/{ref} URLs, and PII in URLs is forbidden.",
                    "maxLength": 100,
                    "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$"
                  },
                  "groups": {
                    "type": "array",
                    "maxItems": 20,
                    "description": "Groups to place the learner in, upserted by ref. Requires `ref` (membership is keyed on it).",
                    "items": {
                      "type": "object",
                      "required": [
                        "ref",
                        "name",
                        "type"
                      ],
                      "properties": {
                        "ref": {
                          "type": "string",
                          "maxLength": 100,
                          "description": "Your group reference, e.g. a course code"
                        },
                        "name": {
                          "type": "string",
                          "maxLength": 200
                        },
                        "type": {
                          "type": "string",
                          "description": "cohort, course, class, curriculum_area, campus, funding_stream, employer, caseload, registration_group, teaching_group, year_group ('team' is managed automatically and rejected)"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Learner provisioned; response includes the assessment URL"
          },
          "402": {
            "description": "Organisation has no assessment credits"
          },
          "409": {
            "description": "group_limit_exceeded — the organisation already has 500 groups"
          },
          "422": {
            "description": "group_autocreate_disabled or team_group_managed"
          }
        }
      }
    },
    "/learners/{ref}": {
      "get": {
        "summary": "Single learner by your reference, with assessment history",
        "parameters": [
          {
            "name": "ref",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Learner with assessments"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/webhooks": {
      "get": {
        "summary": "List registered webhook endpoints",
        "responses": {
          "200": {
            "description": "Endpoints (secrets never returned)"
          }
        }
      },
      "post": {
        "summary": "Register a webhook endpoint for push delivery",
        "description": "Deliveries carry two HMAC-SHA256 signatures: X-DSA-Signature (legacy, over the raw body) and X-DSA-Signature-256 (recommended, `t=<unix seconds>,v1=<hex>` where the MAC covers `<t>.<raw body>` — verify the MAC and reject timestamps outside a 5-minute tolerance to prevent replay). Deliveries also carry X-DSA-Delivery-Id for deduplication, retry with exponential backoff, and dead-letter visibly. The signing secret is returned once. Scoping: omit event_types/group_ids to receive everything — explicit empty arrays are rejected (an empty filter must never silently mean 'deliver nothing'). Event types: assessment.completed (full payload with group_ids), learner.groups.updated (thin payload: {event, learner_ref, added, removed, occurred_at} — re-fetch for details). A subscription scoped by group_ids receives learner.groups.updated when EITHER an added or a removed group matches, so a cohort-scoped subscriber learns of exits.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "HTTPS endpoint"
                  },
                  "event_types": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "assessment.completed",
                        "learner.groups.updated"
                      ]
                    },
                    "description": "Omit for the default (assessment.completed)."
                  },
                  "group_ids": {
                    "type": "array",
                    "maxItems": 50,
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "description": "Omit to receive events for all groups. Ids must be your own groups (422 unknown_group otherwise)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registered; response includes the one-time signing secret"
          }
        }
      }
    },
    "/webhooks/{id}": {
      "delete": {
        "summary": "Remove a webhook endpoint",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Removed"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/groups": {
      "get": {
        "summary": "List groups",
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "CSV of group types (OR)"
          },
          {
            "name": "active",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ]
            }
          },
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of groups",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Group"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid or unknown parameter",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "Create a group",
        "description": "Idempotent on (type, ref): re-posting an existing group returns it with 200. Groups of type 'team' are managed automatically from the organisation's teams and are rejected with 422.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ref",
                  "name",
                  "type"
                ],
                "properties": {
                  "ref": {
                    "type": "string",
                    "maxLength": 100
                  },
                  "name": {
                    "type": "string",
                    "maxLength": 200
                  },
                  "type": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          },
          "200": {
            "description": "Already existed (idempotent upsert)"
          },
          "409": {
            "description": "group_limit_exceeded"
          },
          "422": {
            "description": "team_group_managed"
          }
        }
      }
    },
    "/groups/{id}": {
      "get": {
        "summary": "Group detail with active member count",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The group"
          },
          "404": {
            "description": "Not found"
          }
        }
      },
      "patch": {
        "summary": "Rename or archive a group",
        "description": "Archive (active=false) rather than delete — membership history is funding-audit evidence and is never destroyed. 'team' groups reject modification (422).",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "active": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          },
          "404": {
            "description": "Not found"
          },
          "422": {
            "description": "team_group_managed"
          }
        }
      }
    },
    "/groups/{id}/members": {
      "post": {
        "summary": "Add members (idempotent)",
        "description": "Adds learners by your own refs. Learners already in the group are reported in already_members, not errored. Membership changes cascade updated_at onto the learners' assessments and emit learner.groups.updated webhook events.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "learner_refs"
                ],
                "properties": {
                  "learner_refs": {
                    "type": "array",
                    "maxItems": 500,
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ added: [...], already_members: [...] }"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/groups/{id}/members/remove": {
      "post": {
        "summary": "Remove members",
        "description": "Removal takes a body (never DELETE /members/{ref}) so learner refs stay out of URLs. Removal is soft — the membership history is retained for point-in-time attribution. Triggers scope-exit tombstones in group_id-filtered delta feeds and learner.groups.updated webhook events.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "learner_refs"
                ],
                "properties": {
                  "learner_refs": {
                    "type": "array",
                    "maxItems": 500,
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ removed: [...], not_members: [...] }"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    }
  }
}
