Connect your systems

Read API

Read your plant's machines, events, metrics, OEE and alerts from your own systems: an ERP, MES, BI tool or a script. The API is included in Pro and Plant. To get told when something happens instead of asking, use webhooks. To send data in, see Send pings from a machine.

In the examples, replace https://heartbeat.example.com with your What Started address.

Authentication

An owner or admin creates a key under Settings → Data and API. The key is shown once, so copy it then. Send it on every request:

Authorization: Bearer hbk_…

Keys are read-only and belong to one plant. They are never accepted in the URL. Revoke a key under Settings → Data and API; it stops working immediately.

Endpoints

All responses are JSON and carry a requestId. Errors use one shape:

{ "error": { "code": "unauthorized", "message": "…", "requestId": "…" } }
Status Code Meaning
400 invalid_request A query parameter is wrong or unknown
401 unauthorized Missing, unknown or revoked key
403 forbidden The plant's plan does not include the API
500 internal Our side failed. Retry later

GET /api/v1/machines

Active machines with their current state.

{
  "machines": [
    {
      "id": "…",
      "name": "Press 4",
      "area": "Stamping",
      "status": "run",
      "statusSince": "2026-10-11T06:02:11+00:00",
      "lastSeenAt": "2026-10-11T08:15:40+00:00",
      "signalLost": false,
      "expectedIntervalSeconds": 60,
      "idealRatePerHour": 60,
      "targetRatePerHour": 50,
      "currentPartNumber": "A-100",
      "manualCounts": false,
      "shiftScheduleId": "…",
      "cycleGapMinutes": 5,
      "lastCycleAt": "2026-10-11T08:15:02+00:00",
      "notProducing": false,
      "metrics": { "spindle_load": { "v": 41.5, "at": "2026-10-11T08:15:02+00:00" } }
    }
  ]
}

cycleGapMinutes is how long the machine may run without a cycle before it counts as not producing (null means the check is off). lastCycleAt is when its cycle count last changed, and notProducing is true while a not-producing alert is open. metrics holds the latest value of each custom metric (Pro) and when it arrived; it updates on every ping, also when a ping is too fast to store.

GET /api/v1/events

Status changes, downtime reasons and notes, oldest first.

Parameter Default Notes
machineId One machine
since ISO 8601 time, inclusive
until ISO 8601 time, exclusive
limit 100 1 to 1000
cursor nextCursor from the previous page
{
  "events": [
    {
      "id": 1042,
      "machineId": "…",
      "kind": "downtime",
      "status": "down",
      "reasonCode": "JAM",
      "reasonLabel": "Material jam",
      "note": null,
      "source": "operator",
      "operatorLabel": "Ana",
      "occurredAt": "2026-10-11T07:40:02+00:00"
    }
  ],
  "nextCursor": "1042"
}

nextCursor is null on the last page. To keep a copy in sync, store the last cursor and ask for ?cursor=<it> on a schedule.

GET /api/v1/metrics

Cycle counts, bad part counts, part numbers and custom numbers from pings (Pro). Same parameters as events; each row has recordedAt, cycleCount, badCount, partNumber and custom. Both counts are cumulative counters; a value lower than the one before means the counter was reset.

GET /api/v1/parts

Every part the plant has seen or set up, with the machines it runs on and any rates that override the machine's (null means "use the machine's rate"). Not paged.

{
  "parts": [
    {
      "partNumber": "A-100",
      "description": null,
      "machines": [
        {
          "machineId": "…",
          "idealRatePerHour": 40,
          "targetRatePerHour": 30,
          "lastSeenAt": "2026-10-11T08:15:40+00:00"
        }
      ]
    }
  ]
}

GET /api/v1/shift-schedules

The plant's shift schedules and their shifts. Machines name theirs in shiftScheduleId (null means planned around the clock). Times are wall-clock times in timeZone; weekdays are ISO days (1 = Monday) the shift starts on, and a shift whose end is not after its start ends the next day. Not paged.

{
  "timeZone": "America/Chicago",
  "shiftSchedules": [
    {
      "id": "…",
      "name": "Two shifts",
      "shifts": [
        {
          "name": "Day",
          "weekdays": [1, 2, 3, 4, 5],
          "start": "06:00",
          "end": "14:00",
          "breakMinutes": 30
        },
        {
          "name": "Night",
          "weekdays": [1, 2, 3, 4],
          "start": "22:00",
          "end": "06:00",
          "breakMinutes": 30
        }
      ]
    }
  ]
}

GET /api/v1/part-counts

Parts people logged by hand, at the QR sticker or in the app (Pro). Same parameters as events; each row has machineId, kind, good, bad, scrapReasonCode, scrapReasonLabel, partNumber, operatorLabel, note and occurredAt.

  • kind: "scrap": bad parts on a machine that counts its own parts. They are already in its cycleCount, so they add to bad, not to made.
  • kind: "manual": parts on a machine counted by hand (manualCounts: true). Both good and bad add to made.

GET /api/v1/alerts

No-signal, down, not-producing and metric range alerts (kind is no_signal, down, not_producing or metric_range). Parameters: open=true for unresolved alerts only, limit, cursor. Each row has kind, machineId, openedAt and resolvedAt. Metric range alerts also have metricKey and metricValue, the value that opened the alert; the others have null there.

GET /api/v1/oee

OEE per machine, computed the same way as the Reports page. Parameters:

  • since, until: ISO 8601 times. Default: the last 24 hours. At most 92 days, and never older than the plan's history.
  • by: day (default, the plant's local days), shift (each shift that ran; machines without a schedule fall back to days) or total.
  • machineId: one machine.
{
  "timeZone": "America/Chicago",
  "from": "2026-10-10T05:00:00.000Z",
  "to": "2026-10-11T05:00:00.000Z",
  "machines": [
    {
      "id": "…",
      "name": "Lathe 2",
      "area": null,
      "total": {
        "oee": 0.6976,
        "availability": 0.9375,
        "performance": 0.7586,
        "quality": 0.9813,
        "scheduledSeconds": 54000,
        "plannedStopSeconds": 0,
        "plannedProductionSeconds": 54000,
        "runSeconds": 50625,
        "idealSeconds": 38400,
        "made": 640,
        "good": 628,
        "bad": 12,
        "losses": [{ "label": "Changeover / setup", "seconds": 3375 }],
        "plannedStops": []
      },
      "periods": [{ "label": "2026-10-10 Day", "start": "…", "end": "…", "oee": 0.6222 }]
    }
  ]
}

Each period carries the same fields as total. Ratios are 0 to 1 and null when there is nothing to measure: no planned time, no parts made, or a part made without an ideal rate (performance).

  • Planned production time is shift time, less breaks and planned stops. A machine without a schedule is planned around the clock. A stop is planned when its reason is marked planned. A status without a reason is planned only when it is off.
  • Availability is run time over planned production time.
  • Performance is the ideal time for the parts made, each at its part's ideal rate on that machine or else the machine's, over run time.
  • Quality is good parts over parts made.

History

Nothing older than your plan's history (Pro: 90 days) is returned.

Examples

With curl:

curl "https://heartbeat.example.com/api/v1/events?since=2026-10-11T00:00:00Z&limit=500" \
  -H "Authorization: Bearer $HEARTBEAT_KEY"

With Python, using only the standard library. This reads every event since a time, page by page:

import json
import os
import urllib.parse
import urllib.request

BASE = "https://heartbeat.example.com/api/v1"
KEY = os.environ["HEARTBEAT_KEY"]


def get(path, **params):
    url = f"{BASE}/{path}?{urllib.parse.urlencode(params)}"
    request = urllib.request.Request(url, headers={"Authorization": f"Bearer {KEY}"})
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.load(response)


params = {"since": "2026-10-11T00:00:00Z", "limit": 1000}
while True:
    page = get("events", **params)
    for event in page["events"]:
        print(event["occurredAt"], event["machineId"], event["status"])
    if page["nextCursor"] is None:
        break
    params["cursor"] = page["nextCursor"]

Keep the key out of shared files and screenshots. If one leaks, revoke it and create a new one.