# ORP (job-room.ch) Internal API - Reverse Engineered Source: Angular bundle analysis via Wayback Machine (chunk-OJFRFBH6.js, chunk-SHQXGEIR.js) Date: 2026-03-03 Updated: 2026-03-04 (confirmed working endpoints, browser-context fetch requirement) ## Base URL All relative to https://www.job-room.ch - Base: `/onlineform-service/api/npa` - Search: `/onlineform-service/api/npa/_search` - Actions: `/onlineform-service/api/npa/_action` ## Endpoints | Method | URL | Purpose | |--------|-----|---------| | POST | `/_action/add-work-effort?userId={userId}` | CREATE new work effort | | POST | `/_action/update-work-effort?userId={userId}` | UPDATE work effort (full replace) | | PATCH | `/{proofId}/work-efforts?userId={userId}` | PATCH work effort | | DELETE | `/{proofId}/work-efforts/{workEffortId}` | DELETE a work effort | | GET | `/_search/by-owner-user-id?userId={userId}&page={page}` | List user's records | | GET | `/{proofId}` | Get single proof record | | GET | `/{proofId}/submit` | Submit proof to RAV | | GET | `/{proofId}/pdf-document` | Download PDF | | PATCH | `/{proofId}/work-efforts/{workEffortId}/note` | Update personal note | | DELETE | `/{proofId}/work-efforts/{workEffortId}/note` | Delete personal note | ## CRITICAL: curl Does NOT Work — Browser-Context fetch() Required **curl to ORP API returns 401 even with a valid JWT and all cookies.** The session is bound to browser internals (eIAM SSO cookies, session storage). External HTTP clients cannot replicate this. **The only working approach:** Run fetch() inside the authenticated Playwright browser context via `page.evaluate()`. This executes within the live browser session and inherits all cookies and session state automatically. ## Authentication Every request needs: 1. `Authorization: ` header — JWT from `sessionStorage.getItem('authenticationToken')` (no "Bearer" prefix — the stored value already includes it) 2. `?_ng=ZGU=` query parameter — this is **NOT a CSRF token**, it is simply `base64("de")` = the language key for German. Always use `ZGU=` for German interface. 3. `X-Requested-With: XMLHttpRequest` header ### Getting the token - While logged into job-room.ch in Playwright browser, run: ```js sessionStorage.getItem('authenticationToken') ``` - This returns the full Authorization header value (already includes any prefix) - JWT expires ~10h after login — must re-extract when expired ### _ng parameter (NOT CSRF) - `_ng=ZGU=` is simply `base64("de")` — the language key - It does NOT change per request and is NOT a CSRF token - Always use `ZGU=` (German) regardless of session ## Data Model Two levels: - **ProofOfWorkEfforts** (proofId) - monthly container, one per control period - **WorkEffort** (workEffortId) - individual application entries within a proof Proof statuses: RE_OPENED, SUBMITTED, OPEN, CLOSED ## Create Work Effort Payload ```json { "id": null, "applyDate": "YYYY-MM-DD", "ravAssigned": false, "applyChannel": { "contactPerson": "Name", "email": "hr@company.com", "formUrl": "https://company.com/apply", "phone": "+41...", "types": ["ELECTRONIC"], "address": { "name": "Company Name", "street": "Strasse", "houseNumber": "1", "postalCode": "1000", "country": "CH", "city": "Lausanne", "poBox": null } }, "applyStatus": ["PENDING"], "occupation": "Position Title", "fullTimeJob": true, "rejectionReason": null, "jobAdvertisementId": null } ``` ## Field Values **applyChannel.types** (at least one): - ELECTRONIC - online/email - MAIL - postal - PERSONAL - in-person - PHONE - telephone **applyStatus** (array, at least one): - PENDING - Noch offen - EMPLOYED - Anstellung - REJECTED - Absage - INTERVIEW - Vorstellungsgesprach (can combine: ["REJECTED", "INTERVIEW"]) **fullTimeJob**: true = Vollzeit, false = Teilzeit ## Working browser-context fetch() Pattern ```javascript // MUST run via Playwright page.evaluate() — curl does NOT work (401) await page.evaluate(async (payload) => { const token = sessionStorage.getItem('authenticationToken'); const userId = 'USER_ID_HERE'; const resp = await fetch( `/onlineform-service/api/npa/_action/add-work-effort?userId=${userId}&_ng=ZGU=`, { method: 'POST', headers: { 'Authorization': token, 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) } ); return { status: resp.status }; }, payload); // Returns { status: 201 } on success ``` ## Search (list user's work efforts) Response structure: `{ content: [ { id: proofId, workEfforts: [ { id, applyDate, applyChannel, applyStatus, occupation, ... } ] } ] }` - It's NOT an array - it's `data.content[0].workEfforts` to get the work efforts list ```javascript await page.evaluate(async () => { const token = sessionStorage.getItem('authenticationToken'); const userId = 'USER_ID_HERE'; const resp = await fetch( `/onlineform-service/api/npa/_search/by-owner-user-id?userId=${userId}&_ng=ZGU=`, { headers: { 'Authorization': token, 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } } ); return await resp.json(); }); ``` ## Update Work Effort Status (Confirmed Working 2026-03-17) To update status (e.g. PENDING -> REJECTED), use `/_action/update-work-effort`. This is a **full replace** - send the complete existing payload with updated fields. ```javascript // Step 1: Search to find the work effort const data = await page.evaluate(async () => { const token = sessionStorage.getItem('authenticationToken'); const userId = 'USER_ID_HERE'; const resp = await fetch( `/onlineform-service/api/npa/_search/by-owner-user-id?userId=${userId}&_ng=ZGU=`, { headers: { 'Authorization': token, 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } } ); const data = await resp.json(); const proof = data.content[0]; return proof.workEfforts.find(we => we.applyChannel.address.name.includes('COMPANY_NAME')); }); // Step 2: Update with modified applyStatus and rejectionReason await page.evaluate(async (we) => { const token = sessionStorage.getItem('authenticationToken'); const userId = 'USER_ID_HERE'; we.applyStatus = ['REJECTED']; we.rejectionReason = 'Absage erhalten am DD.MM.YYYY'; const resp = await fetch( `/onlineform-service/api/npa/_action/update-work-effort?userId=${userId}&_ng=ZGU=`, { method: 'POST', headers: { 'Authorization': token, 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(we) } ); return { status: resp.status }; // 201 = success }, data); ``` ## Known User IDs | User | userId | Playwright profile | |------|--------|--------------------| | Matthias | `591489d5-0039-11f1-a99a-462689f09ec3` | `~/.claude/playwright-profiles/matthias/` | | Gina | `703d651c-0412-11f1-a99a-462689f09ec3` | `~/.claude/playwright-profiles/grazyna/` | ### Gina's additional IDs - externalId: `16660479` - March 2026 proofId: `27af9f1f-06ee-11f1-8c9b-967beb1d3317` ## CRITICAL: Always Use API, NEVER Click Through UI **NEVER use Playwright browser_click/browser_type to interact with job-room.ch UI elements.** Always use the internal API via `page.evaluate(fetch(...))`. The UI approach is slow, fragile, and unnecessary. ## Integration Pattern (Confirmed Working) 1. Open job-room.ch in Playwright (just needs to be logged in — any page works) 2. Extract token: `sessionStorage.getItem('authenticationToken')` 3. Get userId from `/user-service/api/current-user?_ng=ZGU=` (or use known userId from table above) 4. Get proofId from `/_search/by-owner-user-id` call (or use known proofId for current month) 5. POST to `/_action/add-work-effort` or `/_action/update-work-effort` via `page.evaluate()` with JSON payload 6. Returns HTTP 201 on success 7. **Do NOT use curl** — it returns 401 regardless of credentials 8. **Do NOT click UI elements** — always use the API via fetch()