{
 "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 400 with a violation list."
     },
     "event": [
      {
       "listen": "test",
       "script": {
        "type": "text/javascript",
        "exec": [
         "pm.test('400 Bad Request', () => pm.response.to.have.status(400));",
         "const body = pm.response.json();",
         "pm.test('error code is validation_failed', () => pm.expect(body.error).to.eql('validation_failed'));",
         "pm.test('violations include email field', () => {",
         "  const fields = body.violations.map(v => v.field);",
         "  pm.expect(fields).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": "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));"
        ]
       }
      }
     ]
    },
    {
     "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. SCORM modules return 409 (SCORM reports its own completion)."
     },
     "event": [
      {
       "listen": "test",
       "script": {
        "type": "text/javascript",
        "exec": [
         "pm.test('202 (accepted/idempotent), 409 (SCORM), 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 /transcript/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/transcript/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'));",
         "}"
        ]
       }
      }
     ]
    }
   ]
  }
 ],
 "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));",
     "        });",
     "    }",
     "}"
    ]
   }
  }
 ]
}