Sessions Cannabis - internal

Labour Against Profit

What the labour dashboard shows, where the numbers come from, and exactly what is still missing. Written so anyone picking this up cold can tell the difference between a number we trust, a number we are waiting on, and a store that looks broken but is not.

Updated September 19, 2026 (Toronto time). Maintained by Ravi, Ecommerce Digital Manager, Sessions Cannabis - ravi@sessions.ca

Update - September 19: Push replied, and we tested their answer

404 versus 403 - the test that settles it

All six calls below were made one after another on September 19, 2026, on the same credential and the same store. This is the whole argument in one table: made-up paths fail differently from blocked paths.

PathCodeResponseMeans
/labour/this-path-does-not-exist404HTML error pagethis is what a wrong path looks like
/totally-made-up-endpoint404HTML error pagesame
/labour/employee403Insufficient permissionsPush's own recommendation - exists, closed to us
/analytics/summary/labour-actuals403Insufficient permissionsexists, closed to us
/analytics/summary/labour-scheduled403Insufficient permissionsexists, closed to us
/shifts200returned 2 shiftsworks, and proves the credential is live

If our paths were wrong they would sit in the 404 rows. They sit in the 403 rows, alongside the endpoint Push told us to use.

Where this stands today

What the dashboard shows now

Live at tools.centilio.com/labour, behind a one-time-code sign-in. Sales and gross profit come from Cova, labour hours from Push Operations.

261 days
of labour history on file, January 1 to September 18. Push history reaches back at least 18 months, so this can be extended whenever it is useful.
46 stores
with Push labour data. 47 are mapped to a Push company; the 8 franchises are excluded by design and show a budget but no actual.
92.6%
of scheduled hours actually worked across the period: 202,761 scheduled against 187,660 worked.
1,244 h
worked but never approved by a manager. Shown separately from approved hours rather than mixed in, because unapproved time is a payroll problem, not a quiet day.

What Push gives us, and what it refuses

Checked live against the Sessions credential on September 18, 2026, across all 47 company IDs. The result is identical on every store, which is what makes this a scope on the credential rather than a per-store setting.

EndpointStatusWhat it carries
/companiesworks49 Sessions companies with their IDs. Two are not stores: a head-office record, and Coverdale, which is not open in Cova yet.
/shiftsworksThe schedule, with real shift start and end times. This is what makes scheduled-by-hour possible. No money.
/clocksworksThe punches: clock in, clock out, approved, regular, overtime, doubletime, breaks. No money.
/analytics/summary/labour-actualsrefusedLabour cost by day and department. One of only two places Push publishes dollars.
/analytics/summary/labour-scheduledrefusedScheduled labour cost by day and department. The other one.
/departmentsrefusedDepartment names. Not blocking anything on its own.

There is no pay rate anywhere else in the API. Roughly 50 candidate endpoint paths were tried - wages, payroll, positions, rates, compensation - and none of them exist. Every valid data expansion on the two working endpoints was also checked: 55 distinct fields between them, not one of which is money.

Raw responses, endpoint by endpoint

Every call below was made against the live Sessions credential on September 18, 2026, store 22936 (Brantford). Employee and record IDs are redacted because this page is readable by the whole team; nothing else is altered. This is the reference for anyone integrating against Push or making the case to them.

/companies - the 49 Sessions companies

GET /companies
HTTP 200

{
  "data": [
    {
      "id": 22915,
      "uuid": "<uuid redacted>",
      "name": "Carlisle - Sessions Cannabis Retail Inc",
      "legalName": "Sessions Cannabis Retail Inc - Carlisle",
      "address": "278 Carlisle Road",
      "city": "Carlisle",
      "provinceState": "ON",
      "country": "Canada",
      "storeNumber": null
    },
    "...48 more"
  ]
}

49 companies returned. Two of them are not stores: a head-office record, and Coverdale, which is not open in Cova yet.

/shifts - the schedule, one shift

GET /shifts?company=22936&start=2026-09-17&end=2026-09-17
HTTP 200

{
  "data": [
    {
      "id": "<redacted>",
      "presetId": "<redacted>",
      "employeeId": "<redacted>",
      "companyId": "<redacted>",
      "positionId": 96889,
      "businessDate": "2026-09-17",
      "shiftChangeId": 0,
      "shiftChangeUserId": 0,
      "shiftChangePickupUserId": 0,
      "userChangeApproved": false,
      "date": {
        "start": "2026-09-17 09:00:00",
        "end": "2026-09-17 16:00:00"
      },
      "time": {
        "start": {
          "hour": 9,
          "minute": 0,
          "formatted": "9:00AM"
        },
        "end": {
          "hour": 16,
          "minute": 0,
          "formatted": "4:00PM"
        }
      },
      "comments": null,
      "breakMinutes": 0,
      "tagId": "<redacted>",
      "published": true
    }
  ],
  "meta": null
}

The important field is date: real shift start and end times. This is what makes scheduled-by-hour possible. There is no money field anywhere in this response.

/shifts?include=position - what a position looks like

GET /shifts?company=22936&start=2026-09-17&end=2026-09-17&include=position
HTTP 200

{
  "position": {
    "id": 96889,
    "name": "Manager",
    "companyId": 22936,
    "showEndTimes": true
  }
}

Five positions exist across the network. Note there is no rate on the position object - only a name.

/clocks - the punches, one clock entry

GET /clocks?company=22936&start=2026-09-17&end=2026-09-17
HTTP 200

{
  "data": [
    {
      "id": "<redacted>",
      "employeeId": "<redacted>",
      "employeePositionId": "<redacted>",
      "businessDate": "2026-09-17",
      "clockIn": "2026-09-17 08:58:34",
      "clockOut": "2026-09-17 15:57:21",
      "hoursApproved": 6.9833333333333,
      "breakMinutes": 15,
      "isApproved": true,
      "isCurrent": 0,
      "isAutoClockout": 0,
      "clockInComments": "",
      "clockOutComments": "",
      "managerComments": "",
      "approvedTime": "Approved: 6H 44M ",
      "regularHours": 6.73,
      "overtimeHours": 0,
      "doubletimeHours": 0,
      "overtimeWeekly": 0,
      "statutoryHolidayWork": 0,
      "createdAt": "2026-09-17 12:58:36",
      "updatedAt": "2026-09-18 15:18:39",
      "breakTypes": [
        {
          "id": 991,
          "description": "Break",
          "durationMinutes": 30,
          "start": "2026-09-17 08:58:00",
          "end": "2026-09-17 08:57:00"
        }
      ]
    }
  ],
  "meta": {
    "page": {
      "previous": null,
      "current": 1,
      "next": null,
      "pageCount": 2,
      "totalCount": 2,
      "totalPages": 1
    }
  }
}

Paid time is regularHours + overtimeHours + doubletimeHours, net of break. Not hoursApproved, which is gross clock time with the break still in it, and which is -1 while a punch awaits approval. The meta.page block is how paging works: 100 rows per page, follow next.

/analytics/summary/labour-actuals - REFUSED

GET /analytics/summary/labour-actuals?company=22936&start=2026-09-17&end=2026-09-17&vacation=true&earnings-deductions=true
HTTP 403

{
  "status": "failed",
  "message": "Insufficient permissions"
}

One of only two endpoints in the whole API that carries labour dollars.

/analytics/summary/labour-scheduled - REFUSED

GET /analytics/summary/labour-scheduled?company=22936&start=2026-09-17&end=2026-09-17&vacation=true
HTTP 403

{
  "status": "failed",
  "message": "Insufficient permissions"
}

The other one.

/departments - REFUSED

GET /departments?company=22936
HTTP 403

{
  "status": "failed",
  "message": "Insufficient permissions"
}

Department names only. Not blocking anything on its own.

What labour-scheduled returns when the permission is on

From Push's own API documentation, not from our account. This is the shape we are waiting for - totalCosts and a per-department cost for each date.

GET /analytics/summary/labour-scheduled?company=12345&start=2022-02-01&end=2022-02-02&vacation=true
HTTP 200

{
  "data": {
    "companyId": 12345,
    "totalCosts": 1233.23,
    "totalHours": 65.6,
    "labourActualByDate": [
      {
        "date": "2022-02-01",
        "costs": 139.29,
        "hours": 5.71,
        "departmentId": 7328,
        "departmentName": "FOH-S"
      },
      "..."
    ]
  }
}

What we are asking Push for

  1. Enable the labour analytics scope on the Sessions API user, covering labour-actuals and labour-scheduled.
  2. That is the whole ask. Once it is on, cost appears across every store and every hour on the next scheduled refresh, with no further work at our end.

Raised with Push under the existing support ticket for the Sessions API credential. Decision on our side, September 18: we want exact rates from Push rather than approximating from a rate card, so this waits on the permission.

How the numbers are defined

Worth reading before querying a figure, because several of these are not the obvious definition.

FigureHow it is calculated
Worked hoursRegular plus overtime plus doubletime, net of break. Not Push's hoursApproved field, which is gross clock time with the break still in it, and which comes back as -1 while a punch is awaiting approval. Summing that field produces negative labour days.
Approved vs pendingKept apart on purpose. A day nobody has signed off would otherwise read as a quiet day rather than an unapproved one.
Hours by hour of dayEach shift and each punch is spread across the hours it covers. Breaks come out proportionally, because Push gives the length of a break but not when it was taken. The buckets are scaled so they always add up to the day total.
TodayShown as "day in progress" with no variance. Anyone currently on shift has no clock-out yet, so their hours count as zero - a live afternoon reads about 8% of schedule where a finished day reads about 88%. The count of people still clocked in is shown instead.
FranchisesBudget yes, actual no. A franchise is not on Push, so there is no spend to show. The budget is still real and a franchisee can be held to it.

Stores that look broken and are not

These are the ones that will prompt a question. None of them are a fault in the dashboard, and none are fixed by the cost permission.

StoreWhat you will seeWhy
SarniaNo labour data at allMapped correctly and the company exists in Push, but it has never had a single punch or shift, going back through 2024. It trades normally in Cova, so the store is open - it is simply not being used in Push. Needs someone on the operations side to check the setup.
London (Wellington)Nothing after September 9Fine until then, then stopped. Same category as Sarnia: a Push setup question, not a data question.
CoverdaleAbsent entirelyExists in Push but is not open in Cova yet, so there is nothing to map it to. It will appear on its own once it trades.
The 8 franchisesBudget, no actualBy design. Not on Push.

Privacy

Labour data is the most sensitive thing either dashboard touches, so the rule is that personal detail never leaves the point of collection.

How it stays current

The refresh runs on its own twice a day, early morning and early afternoon, so the page is never a day stale for anyone looking at it after lunch. Each run pulls the recent Push days, refreshes Cova sales, and pushes both to the server.

It is written to survive being unattended: a failure in the Push step still lets the sales side refresh, on the grounds that half fresh beats a page that quietly stopped updating. Every run is logged.