{
  "info": {
    "_postman_id": "fsri-partner-api-v1-partner",
    "name": "FSRI Partner API",
    "description": "Requests for every FSRI Partner API endpoint (`/api/partner/v1`), grouped by feature.\n\n**Auth is automatic.** A collection-level pre-request script mints an OAuth2 client_credentials token from `{{cognitoTokenUrl}}` using `{{partnerClientId}}` / `{{partnerClientSecret}}`, caches it in `{{accessToken}}`, and refreshes it ~5 minutes before expiry. Every request also sends `x-api-key: {{apiKey}}` \u2014 both are required by the API.\n\n**Setup:** import an environment (Sandbox or Production), fill in `partnerClientId`, `partnerClientSecret`, and `apiKey`, select the environment, and Send. See README.md next to this file.\n\nTest scripts on each request assert status codes and response shape.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{accessToken}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "baseUrl",
      "value": "http://localhost:8090",
      "type": "string"
    },
    {
      "key": "cognitoTokenUrl",
      "value": "https://fsri-partner-api-review-357795418386.auth.us-west-2.amazoncognito.com/oauth2/token",
      "type": "string"
    },
    {
      "key": "partnerClientId",
      "value": "",
      "type": "string"
    },
    {
      "key": "partnerClientSecret",
      "value": "",
      "type": "string"
    }
  ],
  "item": [
    {
      "name": "Public",
      "description": "Endpoints that don't require authentication. Run these first to confirm connectivity.",
      "item": [
        {
          "name": "Health check (GET /ping)",
          "request": {
            "auth": {
              "type": "noauth"
            },
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/ping",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "ping"
              ]
            },
            "description": "ULAPI-41. Public probe; returns 200 + `{status, version, timestamp, checks.database}` when healthy, 503 when a backing service is degraded."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const body = pm.response.json();",
                  "pm.test('status is healthy', () => pm.expect(body.status).to.eql('healthy'));",
                  "pm.test('returns a version', () => pm.expect(body.version).to.be.a('string'));",
                  "pm.test('database check ok', () => pm.expect(body.checks.database).to.eql('ok'));"
                ]
              }
            }
          ]
        },
        {
          "name": "OpenAPI spec (GET /docs.json)",
          "request": {
            "auth": {
              "type": "noauth"
            },
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/docs.json",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "docs.json"
              ]
            },
            "description": "ULAPI-40. Partner-filtered OpenAPI 3.1 spec. Source of truth for partner-visible operations."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const spec = pm.response.json();",
                  "pm.test('title is FSRI Partner API', () => pm.expect(spec.info.title).to.eql('FSRI Partner API'));",
                  "pm.test('exposes /ping', () => pm.expect(spec.paths).to.have.property('/api/partner/v1/ping'));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Users",
      "description": "Partner-scoped user management. All requests use the collection-level OAuth2 token.",
      "item": [
        {
          "name": "Register user (POST /users/register)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"jane.doe@example.com\",\n  \"firstName\": \"Jane\",\n  \"lastName\": \"Doe\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/users/register",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "users",
                "register"
              ]
            },
            "description": "ULAPI-43. Idempotent on (partner, email). Returns 201 + `{uuid, created_at, status}` on first call, 200 with the same UUID on subsequent calls."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('201 or 200', () => pm.expect(pm.response.code).to.be.oneOf([200, 201]));",
                  "const body = pm.response.json();",
                  "pm.test('returns a UUID', () => pm.expect(body.uuid).to.match(/^[0-9a-f-]{36}$/i));",
                  "pm.test('status is active', () => pm.expect(body.status).to.eql('active'));",
                  "// Cache the UUID so subsequent requests (3.4 GET, 3.5 PATCH) can use it.",
                  "pm.collectionVariables.set('lastUserUuid', body.uuid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Register user \u2014 validation error (POST /users/register)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"not-an-email\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/users/register",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "users",
                "register"
              ]
            },
            "description": "Negative test: invalid email + missing firstName/lastName should yield a 422 with a violation list (ULAPI-209)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('422 Unprocessable Content', () => pm.response.to.have.status(422));",
                  "const body = pm.response.json();",
                  "pm.test('body is a ConstraintViolationList', () => pm.expect(body['@type']).to.eql('ConstraintViolationList'));",
                  "pm.test('violations include email', () => {",
                  "  const paths = body.violations.map(v => v.propertyPath);",
                  "  pm.expect(paths).to.include('email');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Get user profile (GET /users/{uuid})",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/users/{{lastUserUuid}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "users",
                "{{lastUserUuid}}"
              ]
            },
            "description": "ULAPI-49. Returns the partner-visible profile for a Member by UUID. Run the *Register user* request first so `lastUserUuid` is populated. Partner isolation: UUIDs that belong to another partner return 404."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const body = pm.response.json();",
                  "pm.test('uuid matches request', () => pm.expect(body.uuid).to.eql(pm.collectionVariables.get('lastUserUuid')));",
                  "pm.test('status is active', () => pm.expect(body.status).to.eql('active'));",
                  "pm.test('JSON-LD context is set', () => pm.expect(body['@type']).to.eql('PartnerUser'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Get user profile \u2014 unknown UUID (GET /users/{uuid})",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/users/00000000-0000-4000-8000-000000000000",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "users",
                "00000000-0000-4000-8000-000000000000"
              ]
            },
            "description": "Negative test: a well-formed UUID that doesn't exist (or belongs to a different partner) returns 404."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('404 Not Found', () => pm.response.to.have.status(404));"
                ]
              }
            }
          ]
        },
        {
          "name": "Update user profile (PATCH /users/{uuid})",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "Content-Type",
                "value": "application/merge-patch+json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"firstName\": \"Jane (updated)\",\n  \"lastName\": \"Doe\",\n  \"phone\": \"+12025551234\",\n  \"countryCode\": \"US\",\n  \"address\": \"123 Main St\",\n  \"city\": \"Springfield\",\n  \"stateProv\": \"IL\",\n  \"postalCode\": \"62701\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/users/{{lastUserUuid}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "users",
                "{{lastUserUuid}}"
              ]
            },
            "description": "ULAPI-50. RFC 7396 merge-patch \u2014 omitted fields are preserved, `null` clears a field. Country code must be ISO 3166-1 alpha-2 and exist in the LMS Country table; phone must be E.164 (+ then 7-15 digits). Requires `partner-api/write` scope."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const body = pm.response.json();",
                  "pm.test('firstName updated', () => pm.expect(body.firstName).to.eql('Jane (updated)'));",
                  "pm.test('phone updated', () => pm.expect(body.phone).to.eql('+12025551234'));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Organizations",
      "description": "Read-only catalog of FSRI organizations partners can attach members to.",
      "item": [
        {
          "name": "List organizations (GET /organizations)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/organizations?page=1&itemsPerPage=25",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "organizations"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "1"
                },
                {
                  "key": "itemsPerPage",
                  "value": "25"
                },
                {
                  "key": "name",
                  "value": "",
                  "disabled": true,
                  "description": "Case-insensitive substring filter on org name"
                }
              ]
            },
            "description": "ULAPI-59. Paginated list of active organizations. Hydra envelope (`hydra:member`, `hydra:totalItems`, `hydra:view`). Use the `name` query param for substring search."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const body = pm.response.json();",
                  "pm.test('Hydra envelope present', () => pm.expect(body['hydra:member']).to.be.an('array'));",
                  "pm.test('totalItems reported', () => pm.expect(body['hydra:totalItems']).to.be.a('number'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Get organization by id (GET /organizations/{id})",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/organizations/1",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "organizations",
                "1"
              ]
            },
            "description": "ULAPI-60. Single active organization. Inactive or unknown IDs return 404."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 or 404', () => pm.expect(pm.response.code).to.be.oneOf([200, 404]));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Categories",
      "description": "Course categories \u2014 partners use these to organize their LMS content display.",
      "item": [
        {
          "name": "List categories (GET /categories)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/categories?page=1&itemsPerPage=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "categories"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "1"
                },
                {
                  "key": "itemsPerPage",
                  "value": "50"
                },
                {
                  "key": "updatedAt[after]",
                  "value": "",
                  "disabled": true,
                  "description": "ULAPI-114 \u2014 delta sync: only categories updated at/after this ISO 8601 timestamp"
                }
              ]
            },
            "description": "ULAPI-61. Paginated list of course categories with `courseCount`. Supports the `updatedAt[\u2026]` delta-sync filter (ULAPI-114)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "pm.test('Hydra envelope present', () => pm.expect(pm.response.json()['hydra:member']).to.be.an('array'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Get category by id (GET /categories/{id})",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/categories/1",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "categories",
                "1"
              ]
            },
            "description": "ULAPI-64. Single category by id."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 or 404', () => pm.expect(pm.response.code).to.be.oneOf([200, 404]));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Courses",
      "description": "Published courses \u2014 what partners surface in their LMS catalog.",
      "item": [
        {
          "name": "List courses (GET /courses)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/courses?page=1&itemsPerPage=25",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "courses"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "1"
                },
                {
                  "key": "itemsPerPage",
                  "value": "25"
                },
                {
                  "key": "categoryId",
                  "value": "",
                  "disabled": true,
                  "description": "Filter to courses tagged with this category id"
                },
                {
                  "key": "updatedAt[after]",
                  "value": "2026-01-01T00:00:00Z",
                  "disabled": true,
                  "description": "ULAPI-114 \u2014 delta sync: only courses updated at/after this ISO 8601 timestamp"
                },
                {
                  "key": "updatedAt[strictly_after]",
                  "value": "",
                  "disabled": true,
                  "description": "Same as `after` but exclusive"
                },
                {
                  "key": "updatedAt[before]",
                  "value": "",
                  "disabled": true,
                  "description": "Inclusive upper bound"
                },
                {
                  "key": "updatedAt[strictly_before]",
                  "value": "",
                  "disabled": true,
                  "description": "Exclusive upper bound"
                }
              ]
            },
            "description": "ULAPI-67. Paginated list of *published* courses. Optional `categoryId` filters by category. Optional `updatedAt[\u2026]` filter (ULAPI-114) supports delta sync."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const body = pm.response.json();",
                  "pm.test('Hydra envelope present', () => pm.expect(body['hydra:member']).to.be.an('array'));",
                  "// Stash the first course id (if any) so subsequent calls can chain on it.",
                  "if (body['hydra:member'].length) {",
                  "  pm.collectionVariables.set('lastCourseId', body['hydra:member'][0].id);",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Get course by id (GET /courses/{id})",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/courses/{{lastCourseId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "courses",
                "{{lastCourseId}}"
              ]
            },
            "description": "ULAPI-68. Single published course (with module ids). Run *List courses* first to populate `lastCourseId`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 or 404', () => pm.expect(pm.response.code).to.be.oneOf([200, 404]));",
                  "if (pm.response.code === 200 && pm.response.json().moduleIds && pm.response.json().moduleIds.length) {",
                  "  pm.collectionVariables.set('lastModuleId', pm.response.json().moduleIds[0]);",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "List courses changed since\u2026 (delta sync)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/courses?updatedAt[after]=2026-01-01T00:00:00Z",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "courses"
              ],
              "query": [
                {
                  "key": "updatedAt[after]",
                  "value": "2026-01-01T00:00:00Z"
                }
              ]
            },
            "description": "ULAPI-114. `updatedAt[after]` returns only records modified at/after the timestamp \u2014 use for scheduled delta syncs instead of full refetches. Also available: updatedAt[before], updatedAt[strictly_after], updatedAt[strictly_before]; supported on /categories, /courses, /modules, /course-progresses, /transcripts."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200', () => pm.response.to.have.status(200));",
                  "pm.test('hydra collection', () => pm.expect(pm.response.json()['hydra:member']).to.be.an('array'));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Modules",
      "description": "Module metadata. Use `GET /modules/{id}/content` (ULAPI-76) for the playback URL.",
      "item": [
        {
          "name": "List modules (GET /modules)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/modules?page=1&itemsPerPage=25",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "modules"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "1"
                },
                {
                  "key": "itemsPerPage",
                  "value": "25"
                },
                {
                  "key": "courseId",
                  "value": "",
                  "disabled": true,
                  "description": "Filter to modules in this course id"
                },
                {
                  "key": "updatedAt[after]",
                  "value": "",
                  "disabled": true,
                  "description": "ULAPI-114 \u2014 delta sync: only modules updated at/after this ISO 8601 timestamp"
                }
              ]
            },
            "description": "ULAPI-72. Paginated list of modules attached to at least one *published* course. Optional `courseId` query param narrows to a specific course. Supports the `updatedAt[\u2026]` delta-sync filter (ULAPI-114)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const body = pm.response.json();",
                  "pm.test('Hydra envelope present', () => pm.expect(body['hydra:member']).to.be.an('array'));",
                  "if (body['hydra:member'].length) {",
                  "  pm.collectionVariables.set('lastModuleId', body['hydra:member'][0].id);",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Get module by id (GET /modules/{id})",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/modules/{{lastModuleId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "modules",
                "{{lastModuleId}}"
              ]
            },
            "description": "ULAPI-74. Single module metadata. Run *List modules* first to populate `lastModuleId`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 or 404', () => pm.expect(pm.response.code).to.be.oneOf([200, 404]));"
                ]
              }
            }
          ]
        },
        {
          "name": "Get module content URL (GET /modules/{id}/content)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "X-User-ID",
                "value": "{{lastUserUuid}}",
                "description": "UUID returned by POST /users/register (or any partner-owned user)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/modules/{{lastModuleId}}/content",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "modules",
                "{{lastModuleId}}",
                "content"
              ]
            },
            "description": "ULAPI-76 + ULAPI-84. Returns `{module_id, content_type, content_url, duration_seconds, mime_type}` pointing at the CloudFront-served asset. Requires the X-User-ID header \u2014 that user must belong to the authenticated partner or the response is 404. Modules unreachable through a published course also return 404.\n\nULAPI-84 side-effect: the call also dispatches `ModuleContentAccessedEvent`, which idempotently creates (or touches) a `CourseProgress` row for the user against every *published* course the module is attached to. Subsequent `GET /course-progresses` calls will surface that row."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 or 404 (drop 400 \u2014 that means X-User-ID misconfigured)', () => pm.expect(pm.response.code).to.be.oneOf([200, 404]));",
                  "if (pm.response.code === 200) {",
                  "  const body = pm.response.json();",
                  "  pm.test('module_id present', () => pm.expect(body.module_id).to.be.a('number'));",
                  "  pm.test('content_url is a URL', () => pm.expect(body.content_url).to.match(/^https?:\\//));",
                  "  pm.test('duration_seconds is numeric', () => pm.expect(body.duration_seconds).to.be.a('number'));",
                  "  pm.test('content_type set', () => pm.expect(body.content_type).to.be.a('string'));",
                  "  pm.test('mime_type set', () => pm.expect(body.mime_type).to.be.a('string'));",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Refresh content URL (GET /modules/{id}/refresh-url)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "X-User-ID",
                "value": "{{lastUserUuid}}",
                "description": "UUID returned by POST /users/register (or any partner-owned user)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/modules/{{lastModuleId}}/refresh-url",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "modules",
                "{{lastModuleId}}",
                "refresh-url"
              ]
            },
            "description": "ULAPI-124. Re-mints a fresh signed content URL + `expires_at` for content already being played \u2014 call when `expires_at - now < 60s` during long-form playback. Same auth + X-User-ID rules as /content."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 or 404', () => pm.expect(pm.response.code).to.be.oneOf([200, 404]));",
                  "if (pm.response.code === 200) {",
                  "  const body = pm.response.json();",
                  "  pm.test('content_url is a URL', () => pm.expect(body.content_url).to.match(/^https?:\\//));",
                  "  pm.test('expires_at is ISO 8601', () => pm.expect(body.expires_at).to.match(/^\\d{4}-\\d{2}-\\d{2}T/));",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Mark module complete (POST /modules/{id}/complete)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "X-User-ID",
                "value": "{{lastUserUuid}}",
                "description": "UUID returned by POST /users/register (or any partner-owned user)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/modules/{{lastModuleId}}/complete",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "modules",
                "{{lastModuleId}}",
                "complete"
              ]
            },
            "description": "ULAPI-130. Completion notification for video/document/resource modules \u2014 FSRI emits the xAPI `completed` statement server-side. No request body. Idempotent: repeat calls on an already-completed module are 202 no-ops. Interactive modules return 409 (they report their own completion)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('202 (accepted/idempotent), 409 (interactive), or 404', () => pm.expect(pm.response.code).to.be.oneOf([202, 409, 404]));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Course Progress",
      "description": "Per-learner course progress (ULAPI-85, ULAPI-87, ULAPI-100).",
      "item": [
        {
          "name": "List course progress (GET /course-progresses)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "X-User-ID",
                "value": "{{lastUserUuid}}",
                "description": "UUID of the partner-owned learner whose progress is being requested."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/course-progresses?page=1&itemsPerPage=25",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "course-progresses"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "1"
                },
                {
                  "key": "itemsPerPage",
                  "value": "25"
                },
                {
                  "key": "course_id",
                  "value": "",
                  "disabled": true,
                  "description": "Filter to a single course's progress for this learner"
                },
                {
                  "key": "module_id",
                  "value": "",
                  "disabled": true,
                  "description": "ULAPI-100 \u2014 entity is course-level so this always returns empty; reserved for forward compat"
                },
                {
                  "key": "status",
                  "value": "in_progress",
                  "disabled": true,
                  "description": "ULAPI-100 \u2014 not_started | in_progress | completed"
                },
                {
                  "key": "sort",
                  "value": "last_activity_at",
                  "disabled": true,
                  "description": "ULAPI-100 \u2014 last_activity_at (default) | started_at | completed_at"
                }
              ]
            },
            "description": "ULAPI-85. Paginated list of CourseProgress rows for the user identified by X-User-ID. Header must resolve to a partner-owned member or the response is 404. Sort: lastActivityAt DESC. Optional ?course_id=N filter."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const body = pm.response.json();",
                  "pm.test('Hydra envelope present', () => pm.expect(body['hydra:member']).to.be.an('array'));",
                  "if (body['hydra:member'].length) {",
                  "  pm.collectionVariables.set('lastCourseProgressId', body['hydra:member'][0].id);",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Get course progress by id (GET /course-progresses/{id})",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "X-User-ID",
                "value": "{{lastUserUuid}}",
                "description": "Must match the learner the progress row belongs to."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/course-progresses/{{lastCourseProgressId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "course-progresses",
                "{{lastCourseProgressId}}"
              ]
            },
            "description": "ULAPI-87. Single CourseProgress row by primary id. Run the *List* request first to populate `lastCourseProgressId`. 404 for unknown id, cross-partner id, or X-User-ID mismatch (no cardinality leak)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 or 404', () => pm.expect(pm.response.code).to.be.oneOf([200, 404]));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Transcripts",
      "description": "Completed-course transcript records (ULAPI-93, ULAPI-95). PDF generation lives in the next folders.",
      "item": [
        {
          "name": "List transcripts (GET /transcripts)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "X-User-ID",
                "value": "{{lastUserUuid}}",
                "description": "UUID of the partner-owned learner whose transcript is being requested."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/transcripts?page=1&itemsPerPage=25",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "transcripts"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "1"
                },
                {
                  "key": "itemsPerPage",
                  "value": "25"
                }
              ]
            },
            "description": "ULAPI-93. Paginated list of completed-course transcripts. Only rows with legacy status passed/completed surface \u2014 in-progress attempts are filtered out. Sort: completedAt DESC."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const body = pm.response.json();",
                  "pm.test('Hydra envelope present', () => pm.expect(body['hydra:member']).to.be.an('array'));",
                  "if (body['hydra:member'].length) {",
                  "  pm.collectionVariables.set('lastTranscriptId', body['hydra:member'][0].id);",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Get transcript by id (GET /transcripts/{id})",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "X-User-ID",
                "value": "{{lastUserUuid}}",
                "description": "Must match the learner the transcript belongs to."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/transcripts/{{lastTranscriptId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "transcripts",
                "{{lastTranscriptId}}"
              ]
            },
            "description": "ULAPI-95. Single transcript by id. Run *List* first to populate `lastTranscriptId`. 404 for unknown, cross-partner, X-User-ID mismatch, or in-progress (non-completed) rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 or 404', () => pm.expect(pm.response.code).to.be.oneOf([200, 404]));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Transcript PDF",
      "description": "Full-transcript PDF generation (ULAPI-97).",
      "item": [
        {
          "name": "Get full transcript PDF (GET /transcripts/pdf)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "X-User-ID",
                "value": "{{lastUserUuid}}",
                "description": "UUID of the partner-owned learner. PDF aggregates all of their completed-course transcripts."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/transcripts/pdf",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "transcript",
                "pdf"
              ]
            },
            "description": "ULAPI-97. Returns application/pdf inline; filename {lastname}-transcript-YYYY-MM-DD.pdf. Body is the rendered PDF \u2014 Postman shows the binary preview. Same legacy-status filter as /transcripts (passed/completed only)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "pm.test('Content-Type is application/pdf', () => pm.expect(pm.response.headers.get('Content-Type')).to.equal('application/pdf'));",
                  "pm.test('Disposition is inline', () => pm.expect((pm.response.headers.get('Content-Disposition') || '').toLowerCase()).to.include('inline'));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Certificate PDF",
      "description": "Per-course certificate PDF generation (ULAPI-98).",
      "item": [
        {
          "name": "Get certificate PDF (GET /transcripts/{id}/certificate/pdf)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "X-User-ID",
                "value": "{{lastUserUuid}}",
                "description": "Must match the learner the transcript belongs to."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/transcripts/{{lastTranscriptId}}/certificate/pdf",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "transcripts",
                "{{lastTranscriptId}}",
                "certificate",
                "pdf"
              ]
            },
            "description": "ULAPI-98. Returns application/pdf as an attachment. Filename {course-slug}-{firstname}-{lastname}.pdf. Run *List transcripts* first to populate `lastTranscriptId`. 404 if the course doesn't issue a certificate, the transcript is in-progress, or partner/user isolation fails."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 or 404', () => pm.expect(pm.response.code).to.be.oneOf([200, 404]));",
                  "if (pm.response.code === 200) {",
                  "  pm.test('Content-Type is application/pdf', () => pm.expect(pm.response.headers.get('Content-Type')).to.equal('application/pdf'));",
                  "  pm.test('Disposition is attachment', () => pm.expect((pm.response.headers.get('Content-Disposition') || '').toLowerCase()).to.include('attachment'));",
                  "}"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Offboarding",
      "item": [
        {
          "name": "Deactivate user (PATCH /users/{uuid})",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              },
              {
                "key": "Content-Type",
                "value": "application/merge-patch+json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"status\": \"inactive\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/users/{{lastUserUuid}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "users",
                "{{lastUserUuid}}"
              ]
            },
            "description": "ULAPI-110. Soft-deactivate the user. Status can flip back to `active` with the same PATCH endpoint."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "pm.test('status flipped to inactive', () => pm.expect(pm.response.json().status).to.eql('inactive'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Anonymize user (DELETE /users/{uuid})",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "x-api-key",
                "value": "{{apiKey}}",
                "description": "API Gateway usage-plan key (required on every request)."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api/partner/v1/users/{{lastUserUuid}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "partner",
                "v1",
                "users",
                "{{lastUserUuid}}"
              ]
            },
            "description": "ULAPI-110. GDPR right-to-be-forgotten. Clears PII, rotates the UUID, sets status=deleted. Returns 204. A repeat DELETE on the same UUID returns 404."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('204 No Content', () => pm.response.to.have.status(204));"
                ]
              }
            }
          ]
        }
      ]
    }
  ],
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// Auto-mint OAuth2 client_credentials token; cache in env until ~5 min before expiry.",
          "const expiresAt = Number(pm.environment.get('tokenExpiresAt') || 0);",
          "const stillValid = pm.environment.get('accessToken') && Date.now() < expiresAt - 300000;",
          "if (!stillValid) {",
          "    const clientId = pm.environment.get('partnerClientId');",
          "    const clientSecret = pm.environment.get('partnerClientSecret');",
          "    const tokenUrl = pm.environment.get('cognitoTokenUrl');",
          "    if (!clientId || !clientSecret || !tokenUrl) {",
          "        console.warn('Set partnerClientId, partnerClientSecret and cognitoTokenUrl in the environment.');",
          "    } else {",
          "        pm.sendRequest({",
          "            url: tokenUrl,",
          "            method: 'POST',",
          "            header: {",
          "                'Content-Type': 'application/x-www-form-urlencoded',",
          "                'Authorization': 'Basic ' + btoa(clientId + ':' + clientSecret),",
          "            },",
          "            body: {",
          "                mode: 'urlencoded',",
          "                urlencoded: [",
          "                    { key: 'grant_type', value: 'client_credentials' },",
          "                    { key: 'scope', value: 'partner-api/read partner-api/write' },",
          "                ],",
          "            },",
          "        }, (err, res) => {",
          "            if (err) { console.error('Token request failed:', err); return; }",
          "            if (res.code !== 200) { console.error('Token endpoint returned ' + res.code + ': ' + res.text()); return; }",
          "            const body = res.json();",
          "            pm.environment.set('accessToken', body.access_token);",
          "            pm.environment.set('tokenExpiresAt', String(Date.now() + body.expires_in * 1000));",
          "        });",
          "    }",
          "}"
        ]
      }
    }
  ]
}