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 itscycleCount, so they add to bad, not to made.kind: "manual": parts on a machine counted by hand (manualCounts: true). Bothgoodandbadadd 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) ortotal.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.