API Reference

Base URL: https://func-actual-ingest-{env}.azurewebsites.net/api

All endpoints require the X-Actual-Api-Key header. Keys are provisioned per device at manufacturing time and per integration at setup time.


Authentication

X-Actual-Api-Key: {api_key}

An invalid or missing key returns:

HTTP 401 Unauthorized
{"error": "Invalid or missing API key"}

Endpoints


POST /events

Ingest a batch of events from a badge.

Auth: Badge API key (provisioned at device registration)

Request

POST /api/events
Content-Type: application/json
X-Actual-Api-Key: {device_api_key}

Body: JSON array of 1–500 event objects. See event-schema.md for full field definitions.

[
  {
    "device_id": "ACT-3F8A21B4",
    "firmware_version": "0.3.1",
    "event_type": "gps",
    "timestamp_utc": "2026-03-14T08:32:15Z",
    "sequence_number": 1042,
    "payload": {
      "lat": 33.749001,
      "lon": -84.387982,
      "accuracy_m": 4.2,
      "fix_type": "3d"
    }
  }
]

Responses

202 Accepted — All events accepted

{
  "accepted": 47,
  "rejected": 0,
  "rejected_events": []
}

207 Multi-Status — Partial acceptance (some events failed validation)

{
  "accepted": 45,
  "rejected": 2,
  "rejected_events": [
    {
      "sequence_number": 1089,
      "reason": "Missing required field: timestamp_utc"
    },
    {
      "sequence_number": 1091,
      "reason": "Invalid event_type: 'gps_update'"
    }
  ]
}

400 Bad Request — All events rejected (payload, schema, or structural error)

{
  "error": "Request body must be a JSON array",
  "accepted": 0
}

413 Payload Too Large — More than 500 events in batch

{
  "error": "Batch limit is 500 events. Received 612.",
  "accepted": 0
}

Idempotency

Duplicate events (same device_id + sequence_number) are silently accepted and counted as accepted. The underlying ON CONFLICT DO NOTHING means no duplicate is written. The response includes them in accepted count. This allows the device to safely retry any upload without risk of duplication.


POST /devices

Provision a new badge. Called during the manufacturing/provisioning workflow — not called from the badge itself.

Auth: Provisioning API key (separate from device API key; stored in provisioning tool only)

Request

POST /api/devices
Content-Type: application/json
X-Actual-Api-Key: {provisioning_api_key}
{
  "device_id": "ACT-3F8A21B4",
  "company_id": "cust-001",
  "notes_device_uid": "dev:000000000000000",
  "notes": "Pilot batch 1, badge #3"
}

Response

201 Created

{
  "device_id": "ACT-3F8A21B4",
  "company_id": "cust-001",
  "status": "inventory",
  "provisioned_at": "2026-03-14T09:00:00Z",
  "api_key": "ak_3F8A21B4_7a2f9e..."
}

The api_key in the response is the device’s ingest API key. This is returned exactly once at provisioning. Store it immediately. It is written to the badge’s flash storage during the provisioning step.


GET /devices/{device_id}/status

Return current device status and last-seen information. Used by the field ops coordinator to check a specific badge without opening Azure Portal.

Auth: Admin API key

Request

GET /api/devices/ACT-3F8A21B4/status
X-Actual-Api-Key: {admin_api_key}

Response

200 OK

{
  "device_id": "ACT-3F8A21B4",
  "company_id": "cust-001",
  "status": "deployed",
  "current_worker_id": "W-007",
  "firmware_version": "0.3.1",
  "last_seen_at": "2026-03-14T07:45:00Z",
  "last_seen_minutes_ago": 47,
  "battery_pct": 72
}

404 Not Found

{"error": "Device not found"}

POST /equipment-tags

Register a BLE equipment tag.

Auth: Admin API key

Request

POST /api/equipment-tags
Content-Type: application/json
X-Actual-Api-Key: {admin_api_key}
{
  "tag_mac": "F4:A7:2E:11:03:BC",
  "company_id": "cust-001",
  "equipment_name": "Truck 03",
  "equipment_type": "truck"
}

Response

201 Created

{
  "tag_mac": "F4:A7:2E:11:03:BC",
  "company_id": "cust-001",
  "equipment_name": "Truck 03",
  "equipment_type": "truck",
  "status": "deployed",
  "created_at": "2026-03-14T09:05:00Z"
}

POST /parcels

Load a parcel polygon for a customer. Typically called from the parcel loading utility when onboarding a new customer or adding new properties.

Auth: Admin API key

Request

POST /api/parcels
Content-Type: application/json
X-Actual-Api-Key: {admin_api_key}
{
  "company_id": "cust-001",
  "parcel_id": "13121-0042-0003",
  "county": "Fulton",
  "state": "GA",
  "address": "1234 Peachtree Rd NW, Atlanta, GA 30309",
  "customer_label": "HOA North Campus",
  "geojson": {
    "type": "Polygon",
    "coordinates": [[
      [-84.389, 33.752],
      [-84.388, 33.752],
      [-84.388, 33.751],
      [-84.389, 33.751],
      [-84.389, 33.752]
    ]]
  }
}

Response

201 Created

{
  "parcel_id": "13121-0042-0003",
  "company_id": "cust-001",
  "loaded_at": "2026-03-14T09:10:00Z"
}

Bulk parcel loading:

Use the parcel loading utility script (infra/scripts/load_parcels.py) rather than calling this endpoint one-by-one when loading a full county shapefile. The script reads a GeoJSON or Shapefile, filters to a specific county and parcel ID list, and POSTs in batches of 100.


Error Response Format

All error responses follow this structure:

{
  "error": "Human-readable error message",
  "code": "MACHINE_READABLE_CODE",
  "detail": "Optional additional context"
}
HTTP Code Meaning
400 Bad request — caller error (malformed JSON, missing fields)
401 Invalid or missing API key
404 Resource not found
409 Conflict — resource already exists (use existing)
413 Payload too large
422 Unprocessable entity — schema validation failure
500 Internal server error — Actual’s problem; logged to App Insights
503 Service unavailable — database or upstream unavailable

Rate Limits

At prototype scale, no rate limiting is enforced. Before production launch:

  • Device ingest endpoint: 100 requests/minute per device (Blues Notecard uploads once every 30 min; this is orders of magnitude headroom)
  • Admin endpoints: 60 requests/minute per API key

Testing

A Postman collection is maintained at infra/postman/actual-api.postman_collection.json.

Quick smoke test (curl):

# Ingest a synthetic GPS event
curl -X POST https://func-actual-ingest-dev.azurewebsites.net/api/events \
  -H "Content-Type: application/json" \
  -H "X-Actual-Api-Key: YOUR_DEV_API_KEY" \
  -d '[{
    "device_id": "ACT-00000001",
    "firmware_version": "0.0.1",
    "event_type": "upload_heartbeat",
    "timestamp_utc": "2026-03-14T12:00:00Z",
    "sequence_number": 1,
    "payload": {}
  }]'

# Expected response:
# HTTP 202: {"accepted": 1, "rejected": 0, "rejected_events": []}