{"openapi":"3.0.0","info":{"title":"Purple Time API","description":"Workforce time & attendance — clock in/out, meal and rest breaks, schedules, timecards, and meal-break compliance exceptions. Resembles tools like ADP Time and Ceridian Dayforce.\n\nThe signature flow is meal-break compliance: `POST /meal-compliance/evaluate` finds employees who have been on the clock past their country's meal threshold, records a `MEAL_BREAK_DUE` exception, and fires the `time.meal_break.due` webhook. Acting on it — `POST /clock` with `action: \"meal_start\"` — records the punch and automatically resolves that exception.\n\n**Where this sits next to Purple HRS.** Purple Time is the source of truth for hours ACTUALLY WORKED: it owns clock in/out punches, the timecards derived from them, schedules, and meal-break compliance. Purple HRS owns the payroll- and HR-facing views of time — its `/timesheets` are weekly hours summaries that feed payroll (no punches, no breaks), its `/time-off-requests` are absence requests, and it remains the system of record for PTO balances. Ask Purple Time \"when did they clock in, and do they owe a break?\"; ask Purple HRS \"how many hours go to payroll this week, and what leave is booked?\".\n\n---\n\n**Acting as a user (`X-PS-Impersonate-User`).** Every endpoint accepts an optional `X-PS-Impersonate-User` request header whose value is the email of the instance user to act as. The effective user resolves in this order: the header if present, else the instance's configured default MCP user, else none. User-scoped paths additionally accept the literal `me` in place of an email, and Graph-style apps also serve `/me/…` twins of their `/users/{email}/…` paths that resolve the same way. This header is honored by all operations but is intentionally omitted from each operation's parameter list so it is not surfaced as a per-tool argument by MCP clients; see the `ImpersonateUser` entry under `components.parameters`.","version":"1.0.0"},"servers":[{"url":"https://marketplace.moveworks.com/api/purple-suite/time"}],"security":[{"patAuth":[]}],"components":{"securitySchemes":{"patAuth":{"type":"http","scheme":"bearer"}},"parameters":{"InstanceId":{"in":"header","name":"X-Instance-ID","required":true,"schema":{"type":"string"}},"ImpersonateUser":{"in":"header","name":"X-PS-Impersonate-User","required":false,"schema":{"type":"string","format":"email"},"description":"Optional. Act as a specific user in the target instance — the value is that user's email (must exist in the instance). Resolution order for the effective user: (1) this header, if set; (2) the instance's configured default MCP user; (3) otherwise no user is bound. User-scoped paths (e.g. /users/{email}/…) also accept the literal \"me\", and Graph-style apps serve /me/… twins of those paths (Graph: /me ≡ /users/{signed-in-user}) — both resolve to whatever this header (or the default MCP user) points at. An explicit path email always wins over the header. The unified MCP server does not send this header — it relies on the per-instance default — so it is primarily for direct REST/HTTP callers."}},"schemas":{"Punch":{"type":"object","properties":{"id":{"type":"string","readOnly":true,"example":"PUNCH-0001"},"employee_email":{"type":"string","format":"email","example":"james.smith@acme.com"},"employee_id":{"type":"string","nullable":true,"example":"EMP-0001"},"type":{"type":"string","enum":["clock_in","clock_out","meal_start","meal_end","break_start","break_end"],"example":"clock_in"},"timestamp":{"type":"string","format":"date-time","example":"2026-08-12T09:02:00.000Z"},"day":{"type":"string","format":"date","description":"Work date (UTC) the punch belongs to — the field the detector scopes by.","example":"2026-08-12"},"source":{"type":"string","enum":["chat","web","timeclock","api"],"example":"chat"},"shift_id":{"type":"string","nullable":true,"example":"SHIFT-0007"},"notes":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}},"required":["employee_email","type","timestamp"]},"Timecard":{"type":"object","properties":{"id":{"type":"string","readOnly":true,"example":"TC-0001"},"employee_email":{"type":"string","format":"email","example":"james.smith@acme.com"},"pay_period_id":{"type":"string","example":"PP-2026-16"},"period_start_date":{"type":"string","format":"date","example":"2026-08-03"},"period_end_date":{"type":"string","format":"date","example":"2026-08-16"},"status":{"type":"string","enum":["open","submitted","approved","signed_off"],"example":"open"},"totals":{"type":"object","description":"Recomputed from the period's punches whenever a punch is recorded.","properties":{"regular_hours":{"type":"number","example":72},"overtime_hours":{"type":"number","example":3.5},"pto_hours":{"type":"number","example":8},"total_hours":{"type":"number","example":83.5}}},"exception_count":{"type":"integer","readOnly":true,"example":1},"approver_email":{"type":"string","format":"email","nullable":true},"submitted_by":{"type":"string","format":"email","nullable":true},"submitted_at":{"type":"string","format":"date-time","nullable":true},"approved_by":{"type":"string","format":"email","nullable":true},"approved_at":{"type":"string","format":"date-time","nullable":true},"approver_notes":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}},"required":["employee_email","pay_period_id"]},"Shift":{"type":"object","properties":{"id":{"type":"string","readOnly":true,"example":"SHIFT-0007"},"employee_email":{"type":"string","format":"email","example":"james.smith@acme.com"},"date":{"type":"string","format":"date","example":"2026-08-12"},"start_time":{"type":"string","format":"date-time","example":"2026-08-12T09:00:00.000Z"},"end_time":{"type":"string","format":"date-time","example":"2026-08-12T17:30:00.000Z"},"position":{"type":"string","example":"Senior Engineer"},"department":{"type":"string","example":"Engineering"},"location":{"type":"string","example":"San Francisco, CA"},"status":{"type":"string","enum":["scheduled","in_progress","completed","missed"],"example":"in_progress"},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}},"required":["employee_email","date"]},"TimeException":{"type":"object","properties":{"id":{"type":"string","readOnly":true,"example":"EXC-0001"},"employee_email":{"type":"string","format":"email","example":"james.smith@acme.com"},"date":{"type":"string","format":"date","example":"2026-08-12"},"type":{"type":"string","enum":["MEAL_BREAK_DUE","MISSED_MEAL","LONG_MEAL","MISSED_PUNCH","LATE_IN","EARLY_OUT","OVERTIME_ALERT"],"example":"MEAL_BREAK_DUE"},"severity":{"type":"string","enum":["low","medium","high"],"example":"high"},"status":{"type":"string","enum":["open","acknowledged","resolved"],"example":"open"},"details":{"type":"object","description":"Type-specific evidence, e.g. hours_worked / threshold_hours / clock_in_time.","additionalProperties":true},"detected_at":{"type":"string","format":"date-time","nullable":true},"resolved_by":{"type":"string","format":"email","nullable":true},"resolved_at":{"type":"string","format":"date-time","nullable":true},"resolution_note":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}},"required":["employee_email","type"]},"MealRule":{"type":"object","properties":{"country":{"type":"string","enum":["IN","AU","US","GB","KR","DE"],"example":"US"},"meal_required_after_hours":{"type":"number","description":"Hours worked after which an unpaid meal break is required.","example":5},"meal_duration_minutes":{"type":"integer","example":30},"reminder_lead_minutes":{"type":"integer","description":"How early before the threshold a reminder should fire.","example":15},"paid":{"type":"boolean","description":"Paid meals keep accruing worked time.","example":false},"description":{"type":"string","example":"California-style 30-minute unpaid meal before the 5th hour."}}},"PayPeriod":{"type":"object","properties":{"id":{"type":"string","readOnly":true,"example":"PP-2026-16"},"start_date":{"type":"string","format":"date","example":"2026-08-03"},"end_date":{"type":"string","format":"date","example":"2026-08-16"},"status":{"type":"string","enum":["open","closed"],"example":"open"},"sign_off_due":{"type":"string","format":"date","example":"2026-08-18"},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"ClockStatus":{"type":"object","properties":{"employee_email":{"type":"string","format":"email","example":"james.smith@acme.com"},"date":{"type":"string","format":"date","example":"2026-08-12"},"clocked_in":{"type":"boolean","example":true},"clock_in_time":{"type":"string","format":"date-time","nullable":true},"hours_worked_today":{"type":"number","example":5.2},"on_meal":{"type":"boolean","example":false},"on_break":{"type":"boolean","example":false},"meal_taken_today":{"type":"boolean","example":false},"threshold_hours":{"type":"number","example":5},"minutes_until_meal_required":{"type":"integer","nullable":true,"description":"Negative when the meal break is already overdue; null when nothing is owed.","example":-12},"meal_break_overdue":{"type":"boolean","example":true},"meal_reminder_due":{"type":"boolean","description":"True inside reminder_lead_minutes of the threshold, or past it.","example":true},"stale_session":{"type":"boolean","description":"True when the session has been open more than 16 hours — a forgotten clock-out rather than a meal-break case.","example":false},"meal_rule":{"type":"object","properties":{"country":{"type":"string","enum":["IN","AU","US","GB","KR","DE"],"example":"US"},"meal_required_after_hours":{"type":"number","description":"Hours worked after which an unpaid meal break is required.","example":5},"meal_duration_minutes":{"type":"integer","example":30},"reminder_lead_minutes":{"type":"integer","description":"How early before the threshold a reminder should fire.","example":15},"paid":{"type":"boolean","description":"Paid meals keep accruing worked time.","example":false},"description":{"type":"string","example":"California-style 30-minute unpaid meal before the 5th hour."}}},"current_shift":{"$ref":"#/components/schemas/Shift","nullable":true},"punches":{"type":"array","items":{"$ref":"#/components/schemas/Punch"}}}},"PunchResult":{"type":"object","properties":{"punch":{"$ref":"#/components/schemas/Punch"},"status":{"type":"object","properties":{"employee_email":{"type":"string","format":"email","example":"james.smith@acme.com"},"date":{"type":"string","format":"date","example":"2026-08-12"},"clocked_in":{"type":"boolean","example":true},"clock_in_time":{"type":"string","format":"date-time","nullable":true},"hours_worked_today":{"type":"number","example":5.2},"on_meal":{"type":"boolean","example":false},"on_break":{"type":"boolean","example":false},"meal_taken_today":{"type":"boolean","example":false},"threshold_hours":{"type":"number","example":5},"minutes_until_meal_required":{"type":"integer","nullable":true,"description":"Negative when the meal break is already overdue; null when nothing is owed.","example":-12},"meal_break_overdue":{"type":"boolean","example":true},"meal_reminder_due":{"type":"boolean","description":"True inside reminder_lead_minutes of the threshold, or past it.","example":true},"stale_session":{"type":"boolean","description":"True when the session has been open more than 16 hours — a forgotten clock-out rather than a meal-break case.","example":false},"meal_rule":{"type":"object","properties":{"country":{"type":"string","enum":["IN","AU","US","GB","KR","DE"],"example":"US"},"meal_required_after_hours":{"type":"number","description":"Hours worked after which an unpaid meal break is required.","example":5},"meal_duration_minutes":{"type":"integer","example":30},"reminder_lead_minutes":{"type":"integer","description":"How early before the threshold a reminder should fire.","example":15},"paid":{"type":"boolean","description":"Paid meals keep accruing worked time.","example":false},"description":{"type":"string","example":"California-style 30-minute unpaid meal before the 5th hour."}}},"current_shift":{"$ref":"#/components/schemas/Shift","nullable":true},"punches":{"type":"array","items":{"$ref":"#/components/schemas/Punch"}}}},"resolved_exceptions":{"type":"array","description":"Exception ids auto-resolved by this punch (a meal_start closes any open MEAL_BREAK_DUE / MISSED_MEAL for the day).","items":{"type":"string"}}}},"MealComplianceResult":{"type":"object","properties":{"date":{"type":"string","format":"date","example":"2026-08-12"},"evaluated":{"type":"integer","description":"Employees with punches on the evaluated date.","example":12},"reminders":{"type":"array","description":"One entry per employee who owes a meal break; each fired time.meal_break.due.","items":{"type":"object","properties":{"employee_email":{"type":"string","format":"email"},"clock_in_time":{"type":"string","format":"date-time","nullable":true},"hours_worked":{"type":"number","example":5.2},"threshold_hours":{"type":"number","example":5},"exception_id":{"type":"string"},"overdue":{"type":"boolean"},"minutes_until_required":{"type":"integer","nullable":true},"date":{"type":"string","format":"date"}}}},"missed_punches":{"type":"array","description":"Sessions open more than 16h — flagged MISSED_PUNCH instead of a meal reminder.","items":{"type":"object","properties":{"employee_email":{"type":"string","format":"email"},"exception_id":{"type":"string"},"clock_in_time":{"type":"string","format":"date-time","nullable":true},"hours_worked":{"type":"number"}}}}}}}},"paths":{"/clock":{"post":{"summary":"Record a punch (clock in/out, meal, break)","description":"The timeclock action a chat plugin calls. `employee_email` defaults to the effective user and accepts the literal \"me\". Punches are validated against the day's existing punches — you cannot clock in twice, clock out mid-meal, or take a second meal break — and a rejected punch returns 422 explaining why. Recording `meal_start` also resolves any open MEAL_BREAK_DUE / MISSED_MEAL exception for that employee and day, which is how the meal-break notification loop closes. Updates the open timecard's totals and today's shift status.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"action":{"type":"string","enum":["clock_in","clock_out","meal_start","meal_end","break_start","break_end"],"description":"Which punch to record.","example":"meal_start"},"employee_email":{"type":"string","description":"Employee to punch for. Defaults to the effective user; accepts \"me\".","example":"me"},"timestamp":{"type":"string","format":"date-time","description":"When the punch happened. Defaults to now."},"source":{"type":"string","enum":["chat","web","timeclock","api"],"example":"chat"},"notes":{"type":"string","example":"Stepping out for lunch"},"shift_id":{"type":"string","description":"Defaults to today's scheduled shift."},"employee_id":{"type":"string","example":"EMP-0001"}},"required":["action"]}}}},"responses":{"201":{"description":"Punch recorded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PunchResult"}}}},"400":{"description":"Missing/invalid action, bad timestamp or source, or \"me\" could not be resolved"},"422":{"description":"Punch is not legal given the day's existing punches (e.g. already clocked in, meal already taken, clock-out during a meal)"}}}},"/clock/status":{"get":{"summary":"Get an employee's live clock and meal-break status","description":"Answers \"am I clocked in?\" and \"do I owe a lunch break yet?\" from the day's punches and the employee's country meal rule. Use `minutes_until_meal_required` (negative when overdue) and `hours_worked_today` to ground notification copy.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"employee_email","schema":{"type":"string"},"description":"Employee to report on. Defaults to the effective user; accepts \"me\".","example":"me"},{"in":"query","name":"employee","schema":{"type":"string"},"description":"Shorthand alias for employee_email."},{"in":"query","name":"date","schema":{"type":"string","format":"date"},"description":"Work date to evaluate (UTC). Defaults to today."}],"responses":{"200":{"description":"Clock status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClockStatus"}}}},"400":{"description":"\"me\" could not be resolved"}}}},"/meal-compliance/evaluate":{"post":{"summary":"Evaluate meal-break compliance and raise reminders","description":"The meal-break detector. Scans every employee with punches on the given date; for each open session within `reminder_lead_minutes` of — or already past — the country's meal threshold, it upserts one open MEAL_BREAK_DUE exception for that employee/day and fires the `time.meal_break.due` webhook. Sessions open more than 16h are treated as a forgotten clock-out and flagged MISSED_PUNCH instead. Exceptions are idempotent (re-running never stacks duplicates) but the webhook fires on every call, so this endpoint doubles as an on-cue demo trigger. Call it with no body to evaluate the whole instance.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"employee_email":{"type":"string","format":"email","description":"Evaluate only this employee. Omit to evaluate everyone."},"date":{"type":"string","format":"date","description":"Work date to evaluate (UTC). Defaults to today."}}}}}},"responses":{"200":{"description":"Evaluation summary","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MealComplianceResult"}}}}}}},"/punches":{"get":{"summary":"List punches","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Punch"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}},"description":"Raw clock events — the punch-level record behind every hours figure in this app. One row per clock in/out, meal start/end or break start/end, stamped with the work date (`day`) the punch belongs to. Purple HRS has no equivalent: its timesheets are weekly summaries with no punch times."},"post":{"summary":"Create punch","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Punch"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Punch"}}}}}}},"/punches/{id}":{"get":{"summary":"Get punch","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Punch"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Update punch","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Punch"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Punch"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete punch","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/timecards":{"get":{"summary":"List timecards","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Timecard"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}},"description":"One employee's timecard for a PAY PERIOD, with totals recomputed from their actual punches (regular / overtime / PTO hours) plus an exception count and approver. This is the authoritative record of hours worked. Do not confuse it with Purple HRS `/timesheets`, which is a WEEKLY hours summary for payroll with no punches behind it — use that one for payroll-facing weekly hours, and this one for what was actually worked."},"post":{"summary":"Create timecard","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timecard"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timecard"}}}}}}},"/timecards/{id}":{"get":{"summary":"Get timecard","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timecard"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Update timecard","description":"Partial update. Status must be one of open, submitted, approved, signed_off; a signed-off timecard cannot be reopened. Prefer /timecards/{id}/submit and /approve for the state machine.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timecard"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timecard"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete timecard","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/timecards/{id}/submit":{"post":{"summary":"Submit a timecard for approval","description":"Employee action: transitions an open timecard to submitted and stamps submitted_by/submitted_at. Emits time.timecard.submitted.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"submitted_by":{"type":"string","format":"email","description":"Defaults to the timecard's employee_email."}}}}}},"responses":{"200":{"description":"Timecard submitted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timecard"}}}},"404":{"description":"Timecard not found"},"422":{"description":"Timecard is not in 'open' status"}}}},"/timecards/{id}/approve":{"post":{"summary":"Approve a submitted timecard (manager sign-off)","description":"Manager action: transitions a submitted timecard to approved and records approved_by/approver_email. Emits time.timecard.approved.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"approved_by":{"type":"string","format":"email","description":"Defaults to the effective user, then the timecard's approver_email."},"approver_notes":{"type":"string"}}}}}},"responses":{"200":{"description":"Timecard approved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timecard"}}}},"404":{"description":"Timecard not found"},"422":{"description":"Timecard is not in 'submitted' status"}}}},"/shifts":{"get":{"summary":"List shifts","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Shift"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Create shift","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Shift"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Shift"}}}}}}},"/shifts/{id}":{"get":{"summary":"Get shift","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Shift"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Update shift","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Shift"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Shift"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete shift","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/exceptions":{"get":{"summary":"List exceptions","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TimeException"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Create exception","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeException"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeException"}}}}}}},"/exceptions/{id}":{"get":{"summary":"Get exception","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeException"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Update exception","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeException"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeException"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete exception","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/exceptions/{id}/resolve":{"post":{"summary":"Resolve a time exception","description":"Marks the exception resolved and records resolved_by/resolved_at. Emits time.exception.resolved. Note that recording a meal_start punch resolves meal exceptions automatically — this endpoint is for the other types (missed punches, late-ins) or for manual closure.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"resolved_by":{"type":"string","format":"email","description":"Defaults to the effective user, then the exception's employee_email."},"resolution_note":{"type":"string","example":"Employee confirmed a 35-minute lunch."}}}}}},"responses":{"200":{"description":"Exception resolved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeException"}}}},"404":{"description":"Exception not found"},"422":{"description":"Exception is already resolved"}}}},"/meal-rules":{"get":{"summary":"List meal rules","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MealRule"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}}},"/meal-rules/{id}":{"get":{"summary":"Get meal rule","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MealRule"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Update meal rule","description":"Tune a country's meal policy (thresholds must be non-negative numbers). Lowering meal_required_after_hours is the quickest way to make the detector fire during a demo.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MealRule"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MealRule"}}}},"404":{"description":"Not found"}}}},"/pay-periods":{"get":{"summary":"List pay periods","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PayPeriod"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}}},"/pay-periods/{id}":{"get":{"summary":"Get pay period","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PayPeriod"}}}},"404":{"description":"Not found"}}}}}}