FreeMaint
FeaturesPricing
AcademyFree maintenance coursesCommunityShared templates & maintenance Q&ADocsGuides & API referenceBlogPlaybooks & product updatesComparisonFreeMaint vs the alternativesChangelogWhat's new
AffiliatesEarn recurring commissionServicesPartners for setup & training
Contact
ENSign inSign up

Product

FeaturesPricing

Resources

AcademyCommunityDocsBlogComparisonChangelog

Partnership

AffiliatesServices

Account

ContactSign in
FreeMaint

Free your maintenance

Product

FeaturesPricingComparisonChangelogSign up

Resources

BlogDocumentationAcademyCommunityResearchSolutionsIndustriesFree templates

Company

AboutServicesAffiliatesContact

Legal

PrivacyTermsRefund PolicyExport Compliance
FreeMaint © 2026 · All rights reserved.GDPR & LGPD CompliantMobile app
Back to Documentation

FreeMaint CMMS

API & GraphQL

API & GraphQL

Build integrations with REST, GraphQL queries and mutations

API Overview

One integration surface: GraphQL — endpoint, authentication, daily quotas, and what an API key actually reaches

FreeMaint has one API surface for machine-to-machine integration: GraphQL, at /api/v1/graphql. The REST routes you see in the browser are the application's own API and are authenticated by a user session — an API key does not reach them. Plan for GraphQL when you integrate.

Header
Authorization: Bearer <token>
Token TTL
Access token: 2 hours. Refresh token: 30 days.

Authentication

Both APIs accept a JWT bearer token in the Authorization header, issued by POST /api/v1/auth/login and valid for 2 hours (refresh with the refresh token). For server-to-server integrations use an API key instead: an admin creates it in Company Settings > API (/company-settings/api), and a person whose role allows it creates their own under Profile > Security > API keys (/profile/api-keys); send Authorization: Bearer fmk_... . A key acts as its owner: it has their permissions and sees only what they see. IMPORTANT: an API key authenticates the GraphQL endpoint (POST /api/v1/graphql), the energy ingest endpoint and /public-api/{whoami,ping} — it does NOT authenticate the REST resource routes, which require a user JWT.

Header
Authorization: Bearer <token>
Token TTL
Access token: 2 hours. Refresh token: 30 days.

Error format

Every error carries an HTTP status, a machine-readable code and a human-readable message. 4xx are client errors (validation, permission, not found); 5xx are server errors and should be retried with exponential backoff. On GraphQL the code is in extensions.code, and the values you can pattern-match are: BAD_REQUEST, UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, CONFLICT, BAD_USER_INPUT and TOO_MANY_REQUESTS. When input validation fails the message names the exact fields at fault — for example "quantity must be a number conforming to the specified constraints" — so you never have to guess which one your system got wrong. A lookup that simply matches nothing is not an error: part(barcode:) returns null.

Example query: query { workOrders(status: "OPEN", limit: 10) { id reference title status priority assetId dueDate } }

GraphQL API

The GraphQL endpoint is POST /api/v1/graphql. It covers a deliberate subset of the product — work orders, assets, asset downtime, parts and their stock movements, meters, purchase orders, locations, vendors, customers and floor plans — not the whole application. Use GraphQL when you would otherwise have to call several endpoints to assemble one screen — for example, a tile showing a work order with its asset details, parts used and total labor cost. The ERP integration guide lists what the schema does not reach.

Rate limits

The quota is a DAILY counter per company, shared between GraphQL and the energy ingest endpoint, and it resets at 00:00 UTC. Business allows 5,000 calls per day, Enterprise 100,000. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. There is no per-minute window and no Retry-After header. On the REST surfaces the refusal is HTTP 403; on GraphQL the HTTP status stays 200 — GraphQL always answers 200 — and the refusal is in errors[0].extensions.code = FORBIDDEN. Do not poll for HTTP 403 on GraphQL: read the errors array.

Example (user session, NOT an API key — a fmk_ key will not authenticate this route): curl -H 'Authorization: Bearer <session-token>' 'https://freemaint.com/api/v1/work-orders?status=OPEN&assigneeId=42&page=1&pageSize=20'

REST API

The REST routes under /api/v1/{resource} are what the FreeMaint web and mobile apps call, authenticated with a user session (JWT). An API key (fmk_...) does NOT authenticate them — it is accepted on /api/v1/graphql and on the energy ingest endpoint only. If you are building an integration, read the GraphQL section below; the REST paths are documented here because you will see them in your browser's network tab, not because a key can call them.

Official SDKs and tooling

There is no official client SDK today. The GraphQL endpoint has introspection enabled, so any GraphQL client (Apollo, graphql-codegen, Insomnia, Postman) can discover the schema by pointing at POST /api/v1/graphql with your API key. We do not currently publish a static OpenAPI document or an SDL file at a fixed URL.

Business
API keys, GraphQL reads and writes, 5,000 calls per day
Enterprise
Everything in Business, 100,000 calls per day, plus the energy ingest endpoint

API keys are not available on Core or Starter.

Tier availability

Business
API keys, GraphQL reads and writes, 5,000 calls per day
Enterprise
Everything in Business, 100,000 calls per day, plus the energy ingest endpoint

URLs include /v1 — when we introduce breaking changes (rare), we ship /v2 alongside and keep /v1 supported for at least 12 months before deprecation.


Authentication

JWT for user sessions, API keys for integrations

FreeMaint accepts two auth mechanisms: JWTs (issued by /auth/login, used by our web and mobile apps) and API keys (created in /company-settings/api, used by your own integrations).

API key (server integrations)

  1. {"title":"Open /company-settings/api or /profile/api-keys","description":"Admins create keys in Company Settings > API, from the Business plan upward. A person whose role has the \"Create personal API keys\" permission creates their own under Profile > Security > API keys."}
  2. {"title":"Click 'New key'","description":"Optionally set an expiration and an access level (Read only by default). The raw key is shown ONCE — copy it immediately."}
  3. {"title":"Use it","description":"Send Authorization: Bearer fmk_<rawKey> on every request. Format: starts with fmk_ followed by 32 hex characters."}

JWT (user sessions)

  1. {"title":"POST /auth/login","description":"Body: {email, password}. Returns {access_token, refresh_token}."}
  2. {"title":"Send Authorization header","description":"On every request: Authorization: Bearer <access_token>."}
  3. {"title":"Refresh when expired","description":"POST /auth/refresh with the refresh_token to get a new access_token. Access tokens last 2 hours."}

Security notes

  • API keys are stored as SHA-256 hashes — once you close the reveal dialog, the raw value is unrecoverable
  • Revoking a key disables it immediately; the row is kept for audit
  • Each key is scoped to a single company; cross-tenant requests always 401
  • Keys can have an optional expiresAt for short-lived integrations
  • Access levels, a ceiling on top of the owner's permissions: READ (the default — queries only), READ_STOCK (queries + adjustStock — ideal for a consume-only kiosk), FULL (every operation the owner's role allows)
  • Each key acts as its owner: every call carries their permissions and their data visibility, and what it writes is signed with their name. If the owner is deactivated, the key is refused (401, code API_KEY_OWNER_INACTIVE) until an admin hands it to another person in Company Settings > API — the key value does not change.

Testing an integration

There is no separate sandbox environment: an API key always addresses the real data of the company it belongs to. Two ways to test safely. (1) Sign up a second, free company and use it as your test tenant — the isolation between companies is the same mechanism that separates every customer, so nothing you do there can reach your production data. (2) For read-only work, create a key with the READ access level: it can run every query and no mutation, so it cannot change anything at all. When you are ready to write, a READ_STOCK key allows adjustStock and nothing else.

Compromise a key? Revoke it from /company-settings/api — takes effect within seconds.


Quotas & Rate Limits

Daily caps per tier, with X-RateLimit-* headers

FreeMaint API enforces a daily quota per company, reset at midnight UTC. The cap depends on your tier; counters and remaining are exposed as response headers.

Daily caps

  • Core, Starter — 0 (an API key requires Business)
  • Business — 5,000 calls/day (GraphQL and the energy ingest endpoint share the same counter)
  • Enterprise — 100,000 calls/day
  • On-Premise — unlimited (cap = null)

What counts as one call

The counter increments per OPERATION, not per HTTP request. Grouping 50 aliased mutations into a single request still consumes 50 units. Batching therefore saves round-trips, never quota. To keep a recurring sync affordable, pull only what changed with parts(updatedSince: "...") instead of re-reading the whole catalogue.

What doesn't count

Only /public-api/whoami is free — it does not decrement your counter, so you can verify a key and its tier without burning quota. Everything on /api/v1/graphql is quota-counted, including the whoami query, and so is the energy ingest endpoint.

When you exceed the cap

Once you hit your daily cap, every further call is refused with the message 'Daily API cap reached'. On the REST surfaces (/public-api/ping, the energy ingest endpoint) that is an HTTP 403. On GraphQL the HTTP status is 200 and the refusal sits in errors[0].extensions.code = FORBIDDEN, with originalError.statusCode = 403. The counter resets at 00:00 UTC. There is no soft cap, no burst allowance and no per-minute window — the window is the day.

Response headers

  • X-RateLimit-Limit — your tier's daily cap
  • X-RateLimit-Remaining — calls left today (decrements on every quota-counted call)
  • X-RateLimit-Reset — Unix timestamp of midnight UTC (next reset)

Monitor X-RateLimit-Remaining in your integration's dashboard so you can warn yourself before hitting the cap.


GraphQL Queries (Read)

Fetch data with type-safe GraphQL queries

The GraphQL endpoint at /api/v1/graphql exposes 16 read queries: whoami, workOrders, workOrder(id | reference), assets, asset(id), assetDowntimes, meters(assetId), parts (with lowStock filter), part (by id or barcode), stockMovements, floorPlans(locationId), locations, vendors, customers, purchaseOrders, purchaseOrder(id | reference | erpReference). Every list can be paged to the end with limit (1-200) plus offset — except stockMovements, which pages with afterId.

Endpoint

POST https://freemaint.com/api/v1/graphql with Content-Type: application/json. Each query consumes 1 from your daily quota.

Example: list work orders

query { workOrders(limit: 10, status: "OPEN") { id title priority status assetId assetName locationId locationName dueDate } } Returns up to 200 rows; the default limit is 50.

Available filters

  • workOrders(limit, offset, status, updatedSince) — newest first; page with limit (1-200, default 50) plus offset; status string filter; updatedSince returns only work orders modified since that moment; work orders in the recycle bin are never returned
  • workOrder(id:) / workOrder(reference:) — one work order, found by its id or by the reference your team quotes (WO-2026-11-016, case-insensitive); pass exactly one. Every work order also returns assetName and locationName, the names a form or a permit prints
  • assets(limit, offset, updatedSince) — newest first; page with limit (1-200, default 50) plus offset; updatedSince returns only assets modified since that moment
  • assetDowntimes(assetId, openOnly, since, limit, offset) — machine stops, newest first; page with limit (1-200, default 50) plus offset; openOnly=true returns stops that have not ended yet; since (ISO 8601) returns stops that STARTED at or after a timestamp, which is the cheap way to catch up after a missed delivery
  • parts(limit, offset, lowStock, updatedSince) — page with limit (1-200) plus offset; updatedSince returns only parts modified since that moment; lowStock=true returns only stocked items at or below minQuantity
  • locations, vendors, customers (limit, offset, updatedSince) — ordered by name; page with limit (1-200, default 200) plus offset; updatedSince returns only rows modified since that moment
  • part(id, barcode) — single-part lookup; barcode matches barcode, QR code, part number, reference and custom reference, ideal for scanners
  • floorPlans(locationId) — floor plans of a location, with their markers (percentage x/y coordinates); lets a kiosk render where a part is stored
  • meters(assetId, limit, offset) — meters (running hours, counters, odometers, gauges), newest first; page with limit (1-200, default 50) plus offset; assetId narrows to one machine; lastReadingValue and lastReadingDate give the latest reading
  • purchaseOrders(limit, offset, status, vendorId, updatedSince) — purchase orders with their lines (each with receivedQuantity), their deliveries (receipts) and vendor, newest first; page with limit (1-200, default 50) plus offset; status is one of PENDING, APPROVED, ORDERED, PARTIALLY_RECEIVED, RECEIVED, CANCELLED; updatedSince returns only orders modified since that moment
  • purchaseOrder(id, reference, erpReference) — one purchase order, found by its id, its FreeMaint reference (PO-12) or the erpReference your ERP wrote back; pass exactly one
  • stockMovements(limit, afterId, since, partId, workOrderId, type) — the stock ledger, oldest first; page with limit (1-200, default 50) and afterId: pass the id of the last movement you received to get the next page, which stays exact while new movements are being written; since (ISO 8601) starts from a date; type is one of ADJUSTMENT, PURCHASE, WORK_ORDER, TRANSFER, RETURN, SCRAP, INITIAL, CORRECTION, INSPECTION

Schema introspection

Introspection is enabled. To explore the full schema interactively — every query, mutation and input type with its fields — point a desktop GraphQL client (Altair, Insomnia or Postman) at POST /api/v1/graphql with your Bearer token. Browser-hosted explorers such as Apollo Sandbox or online GraphiQL are refused by our CORS policy.

Fetch one part (by id or barcode)

Fetch a single part by id or barcode, with part(id: N) or part(barcode: "CODE"). barcode matches the barcode, the QR code, the part number and the reference (case-insensitive, active parts only) — the same matching the in-app scanner uses — and returns null when nothing matches. Example: query { part(barcode: "ABC-123") { id name description partNumber quantity minQuantity maxStockLevel unitOfMeasure imageUrl area location { name } vendor { name website } } } The part also returns description, minQuantity, maxStockLevel, imageUrl and the nested location and vendor — enough to fill a kiosk item screen in a single query. It also matches your own customReference, so an integration can find a part by the code it carries in your own system.

Combine several queries in one request: { workOrders(limit:5){ id } assets(limit:5){ id } } — it counts as a single quota hit.

Meter custom fields: meters { id name customFields { name label fieldType value } } returns one entry per field defined for meters, in display order; value is text, or null when that meter has no value. Values are read-only through the API.


GraphQL Mutations (Write)

Create, update and delete records via GraphQL

From the Business tier, the GraphQL endpoint supports 12 write mutations: create, update and delete on Work Orders, Assets and Parts, plus adjustStock for inventory movements. Mutations go through the same services the application itself uses, so audit logs, notifications and reference auto-generation (WO-XX, ASS-XX, PART-XX) all behave identically. The eleventh, addMeterReading, records meter readings (running hours, counters) — see below. The twelfth, setPurchaseOrderErpReference, writes your ERP's own document number onto a purchase order (FULL-access key only) and fires no webhook, so your ERP never receives its own write back.

Attribution

Every mutation runs as the key's owner — the person the key acts as — with their permissions, their data visibility and their name in the audit log. A key never approves a workflow step, even when its owner could.

Available mutations

  • createWorkOrder(input) → WorkOrder
  • updateWorkOrder(id, input) → WorkOrder
  • deleteWorkOrder(id) → DeleteResult
  • createAsset(input) → Asset
  • updateAsset(id, input) → Asset
  • deleteAsset(id, force?) → DeleteResult
  • createPart(input) → Part
  • updatePart(id, input) → Part
  • deletePart(id, force?) → DeleteResult
  • adjustStock(id, quantity, reason?, vendorId?, documentNumber?) → Part
  • addMeterReading(meterId, value?, incrementValue?, date?, notes?, confirmRegression?) → MeterReading
  • setPurchaseOrderErpReference(id, erpReference?) → PurchaseOrder

Common errors

  • GRAPHQL_VALIDATION_FAILED — the input shape does not match (for example a required title is missing)
  • BAD_REQUEST — value validation failed (for example a title over 500 characters)
  • FORBIDDEN — your plan is below Business
  • NOT_FOUND — the id does not exist, or belongs to another company
  • FORBIDDEN — the key's access level is too low (for example a READ key calling a mutation)

Example: create a work order

Create a work order: mutation($i: WorkOrderCreateInput!) { createWorkOrder(input: $i) { id title reference status createdAt } } with the variables {"i": {"title": "Pump #3 leak", "priority": "HIGH"}}

Input shape

Each mutation has an InputType with safe fields only — companyId, sequenceNumber, reference and createdById are filled server-side. You only pass title, description, status, priority, assetId, locationId, etc. Part inputs also accept barcode, maxStockLevel, locationId and vendorId, and quantity/minQuantity/maxStockLevel take decimals (e.g. 0.5). Enumerated fields are checked: priority must be one of LOW, MEDIUM, HIGH, CRITICAL; a work order's status one of OPEN, SCHEDULED, IN_PROGRESS, ON_HOLD, COMPLETED, VALIDATED, CANCELLED, CLOSED; an asset's status one of OPERATIONAL, DEGRADED, DOWN, MAINTENANCE, STANDBY, RETIRED. Anything else is refused with BAD_REQUEST. Part inputs also accept customReference — your own code for the part (its ERP code, for instance), which part(barcode:) then matches. It is not enforced unique: that is yours to guarantee.

Recording meter readings

addMeterReading records a reading on a meter — running hours, a counter, an odometer, a gauge — so an external system (SCADA, PLC gateway, ERP) can feed meter-based preventive maintenance without anyone typing the number in. First find the meter id: query { meters(assetId: 7) { id reference name unit type lastReadingValue lastReadingDate } } Then send EITHER value (the absolute total the meter shows) OR incrementValue (for example the hours run since the last reading, which the server adds to the previous reading) — exactly one of the two: mutation { addMeterReading(meterId: 12, value: 1250.5, date: "2026-09-25T06:00:00Z") { id value date isAnomaly } } date is optional (ISO 8601, defaults to now) and may be in the past, never in the future. On RUNTIME and COUNTER meters, a value lower than the previous reading is refused with BAD_REQUEST — after a meter reset or replacement, send it again with confirmRegression: true. The reading goes through the same service as the meter screen in the app: if it is the latest one, meter triggers and meter-based preventive maintenance fire exactly as they do for a reading typed in by a technician. This mutation needs a FULL-access key.

Retrying safely (Idempotency-Key)

A network timeout does not tell you whether a mutation ran. Send an Idempotency-Key header (8 to 64 letters, digits or dashes — a UUID works) with every request that writes, and send the SAME key when you retry it. For 7 days, a retry with the same key returns the stored result of the first call (the response carries "Idempotent-Replayed: true") instead of running the mutation again: no second work order, no doubled stock movement.

  • One key per write. In a request with several aliased mutations, each mutation is recorded on its own under that key. The values sent are not compared: the same key with different values returns the result of the first call.
  • A call that failed keeps nothing: retrying it with the same key runs it again.
  • IDEMPOTENCY_KEY_REUSED — under this key, the same name (field or alias) already ran a different mutation, or another caller used the key: use a new key for a new write.
  • IDEMPOTENCY_IN_PROGRESS — the first call is still running: wait a few seconds and retry.

Adjusting stock

To change a part's quantity, use adjustStock with a signed delta — negative to consume, positive to receive. It updates the quantity and writes a StockMovement audit row in one atomic transaction, and never lets stock fall below zero. For a part whose stock is tracked per location, pass locationId: the movement is applied to that location, and without it the call is refused: errors[0].extensions.code is BAD_REQUEST and extensions.reason is LOCATION_STOCK_REQUIRES_LOCATION. updatePart(quantity) sets an absolute value instead: it is refused on a part tracked per location for the same reason, and otherwise records the difference as an ADJUSTMENT movement. You can also record where the goods came from: vendorId and documentNumber (the supplier's invoice or delivery-note number, 64 characters max) are stored on the movement itself, so the history shows who supplied them and under which document. Both are optional — an ordinary consumption sends neither. The supplier must belong to your company.

If you get a DateTime serialization error, check your client's DateTime scalar — FreeMaint emits ISO 8601 strings.


Webhooks (Outbound)

Push FreeMaint events into your own systems in real time

Webhooks are the push half of integrations: instead of your system polling FreeMaint, FreeMaint POSTs to your URL the moment something happens. Set them up in Company Settings > Webhooks (/company-settings/webhooks). Available from Starter (100 deliveries/day; Business and above unlimited).

Caps

  • Starter — 100 deliveries/day per company
  • Business — Unlimited
  • Enterprise — Unlimited
  • The counter is per delivery attempt, so three webhooks subscribed to the same event consume three units
  • Daily counter resets at 00:00 UTC

{ "event": "part.consumed", "timestamp": "2026-08-19T21:04:11.000Z", "data": { "part": { "id": 812, "name": "Bearing 6204", "part_number": "RLM-204", "reference": "PART-812", "custom_reference": "BC-10045", "unit_cost": 12.5, "unit_of_measure": "ea" }, "quantity": -2, "previous_quantity": 48, "new_quantity": 46, "work_order": { "id": 5521, "reference": "WO-2026-0412", "title": "Bearing replacement" }, "asset": { "id": 91, "name": "Separator 2" }, "location": { "id": 7, "name": "Plant warehouse" }, "user": { "id": 5627, "name": "A. Technician" }, "stock_movement_id": 88213, "occurred_at": "2026-08-19T21:04:11.000Z" } }

part.consumed — mirroring stock into an ERP

This is the event to subscribe to when another system (ERP, billing, inventory, accounting) must reflect what maintenance actually used. quantity is signed: negative when the part is issued to the job, positive when it is returned by removing it from the work order — so a reversal in FreeMaint produces a matching reversal document downstream. stock_movement_id is stable across retries and replays: use it as your idempotency key and you will never post the same issue twice. One thing to know when the traffic runs both ways: the GraphQL adjustStock mutation does NOT fire this event. That is deliberate — a stock correction your own system writes into FreeMaint must never come back to you as an echo you post twice. Only consumption recorded on a work order inside FreeMaint raises part.consumed. The part also carries custom_reference: your own code for it, the customReference set through createPart or updatePart, so a system that owns the catalogue can match each line without keeping a table of FreeMaint ids.

Delivery and signature

Each delivery is a POST with a JSON body and an X-FreeMaint-Signature header in the form t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of the string "<t>.<raw body>" keyed with your webhook secret. Verify against the RAW body, before any JSON parsing or re-serialisation. We also send X-FreeMaint-Event and X-FreeMaint-Delivery-Id. A 2xx marks the delivery delivered; network errors and 5xx are retried up to 3 times with exponential backoff (about 2s, 4s, 8s); a 4xx is treated as a permanent rejection and is NOT retried.

Available events

  • work_order.created — a work order was created
  • work_order.completed — a work order moved to Completed
  • work_order.status_changed — a work order moved to any status; the payload carries new_status. Fires alongside work_order.completed, which is unchanged
  • request.created — a maintenance request was submitted
  • request.status_changed — a request was approved, rejected or cancelled
  • part.low_stock — a part fell to or below its reorder point
  • part.out_of_stock — a part hit zero
  • part.low_stock_summary — one daily digest per company listing the parts at or below their reorder point, including parts that never moved
  • part.out_of_stock_summary — the same daily digest, for the parts sitting at zero
  • part.consumed — a part was consumed on, or returned from, a work order
  • compliance.chain_divergence — the audit hash-chain broke (Enterprise)
  • asset.downtime_started — a machine stop was opened
  • asset.downtime_ended — a machine stop was closed; the duration can now be booked
  • report.daily_summary — the day's completed work, once a day at the hour you choose
  • purchase_order.created — a purchase order was created; the payload carries the order (reference, status, total_cost, currency, expected_date, erp_reference) and its vendor
  • purchase_order.status_changed — a purchase order moved to another status (approved, ordered, cancelled…); the payload carries previous_status and new_status
  • purchase_order.received — a delivery was received against a purchase order (sent for every delivery, partial ones included); the payload lists every line with quantity_ordered, quantity_received (this delivery), quantity_received_total and quantity_remaining, plus receipt_id, fully_received and closed_short; new_status is PARTIALLY_RECEIVED while goods are still expected and RECEIVED once complete or closed

Photos (optional)

When Include photos is on for a webhook (off by default), "work_order" and "request" objects carry a "photos" array: up to 4 HTTPS links to JPG, PNG, GIF or WebP images, newest first. Each link opens the image without signing in for 7 days, so store the file on your side if you need it longer. A replay sends the same links. Other events and other webhooks are unchanged. On "work_order.created" and "request.created", a webhook with Include photos is sent 90 seconds after the event (the "timestamp" field keeps the event's time), so that the photos uploaded right after the record are included.

Replaying a delivery

Every attempt is kept in the delivery history on the webhook, with its status and response code. Any settled delivery can be replayed from there — it re-sends the stored payload unchanged, so the business keys inside it (including stock_movement_id) stay the same and a receiver that deduplicates correctly will not double-post. This is how you recover after your endpoint was down or rejected a request by mistake.

Verify the X-FreeMaint-Signature header on every request, against the raw request body, so a spoofed call cannot move stock in your ERP.

URL requirements

  • https:// only — plain http is rejected
  • Port 443 or 8443 only
  • The host must be publicly reachable — loopback, private and carrier-grade NAT ranges are refused
  • No credentials in the URL (https://user:pass@host is rejected)
  • A system that only exists inside your own network needs a public endpoint or a relay (n8n, Make, Zapier) in front of it

Code Examples

curl, JavaScript and Python snippets

Copy-paste examples for the most common API operations. Set FMK to your API key (it starts with fmk_). Keep the code exactly as written: field names, header names and enum values are part of the protocol and are never translated.

curl

  • Verify the key: curl -H "Authorization: Bearer $FMK" https://freemaint.com/api/v1/public-api/whoami
  • List work orders: curl -X POST -H "Authorization: Bearer $FMK" -H "Content-Type: application/json" -d '{"query":"{ workOrders(limit:5){ id title status } }"}' https://freemaint.com/api/v1/graphql
  • Consume one unit of stock: curl -X POST -H "Authorization: Bearer $FMK" -H "Content-Type: application/json" -d '{"query":"mutation { adjustStock(id:812, quantity:-1, reason:\"ERP sync\"){ id quantity } }"}' https://freemaint.com/api/v1/graphql

JavaScript (fetch)

With fetch, in Node.js or the browser: const r = await fetch('https://freemaint.com/api/v1/graphql', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.FMK}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: '{ workOrders(limit:5){ id title } }' }) }); const data = await r.json();

Python: barcode kiosk

Scan a code, look up the part, then subtract one unit: import requests; H={'Authorization': f'Bearer {FMK}'}; U='https://freemaint.com/api/v1/graphql'; code=input('Scan: '); part=requests.post(U,headers=H,json={'query':'query($c:String!){ part(barcode:$c){ id name description quantity minQuantity maxStockLevel unitOfMeasure imageUrl area location{name} vendor{name website} } }','variables':{'c':code}}).json()['data']['part']; requests.post(U,headers=H,json={'query':'mutation($id:Int!){ adjustStock(id:$id,quantity:-1,reason:"Kiosk"){ id quantity } }','variables':{'id':int(part['id'])}})

Python (requests)

With the requests library: import requests; r = requests.post('https://freemaint.com/api/v1/graphql', headers={'Authorization': f'Bearer {FMK}'}, json={'query': '{ workOrders(limit:5){ id title } }'}); print(r.json())

Always check X-RateLimit-Remaining in the response — your integration should warn you before exhausting the daily cap.


ERP & System Integration Guide

Scope an integration before you build it: what the API reaches, what it does not, and where the boundaries are

This guide is for a developer scoping an integration between FreeMaint and another system — an ERP, a SCADA historian, a warehouse or an accounting package. The other articles in this section explain how to call things. This one is deliberately about boundaries, because the expensive mistake is not a wrong header: it is discovering halfway through a build that a capability you assumed does not exist. Every limit below is stated on purpose.

Webhooks — the event catalogue

Webhooks are the push half of an integration: instead of polling, FreeMaint POSTs to your URL the moment something happens. You configure them in Company Settings > Webhooks (/company-settings/webhooks) and subscribe each webhook to whichever events you need. The catalogue is a fixed list.

  • work_order.created — a work order was created
  • work_order.completed — a work order reached Completed
  • work_order.status_changed — a work order moved to any status; the payload carries new_status. Fires alongside work_order.completed, which is unchanged
  • request.created — a maintenance request was submitted
  • request.status_changed — a request was approved, rejected or cancelled
  • part.low_stock — a part fell to or below its minimum
  • part.out_of_stock — a part reached zero; a separate event, not a variant of low_stock
  • part.low_stock_summary — one daily digest per company listing the parts at or below their reorder point, including parts that never moved
  • part.out_of_stock_summary — the same daily digest, for the parts sitting at zero
  • part.consumed — a part was consumed on, or returned from, a work order; quantity is signed
  • compliance.chain_divergence — the audit hash-chain broke (Enterprise)
  • asset.downtime_started — a machine stop was opened
  • asset.downtime_ended — a machine stop was closed; the duration can now be booked
  • report.daily_summary — the day's completed work, once a day at the hour you choose
  • purchase_order.created — a purchase order was created; the payload carries the order (reference, status, total_cost, currency, expected_date, erp_reference) and its vendor
  • purchase_order.status_changed — a purchase order moved to another status (approved, ordered, cancelled…); the payload carries previous_status and new_status
  • purchase_order.received — a delivery was received against a purchase order (sent for every delivery, partial ones included); the payload lists every line with quantity_ordered, quantity_received (this delivery), quantity_received_total and quantity_remaining, plus receipt_id, fully_received and closed_short; new_status is PARTIALLY_RECEIVED while goods are still expected and RECEIVED once complete or closed

What the API does not cover at all

The GraphQL schema contains work orders, assets, asset downtime, parts and their stock movements, meters, purchase orders, locations, vendors, customers and floor plans. Nothing else. The following are fully working parts of FreeMaint that have NO API surface today: they cannot be read, written or subscribed to from an integration, and no combination of the existing queries reaches them.

  • Inspections — not the templates, the scheduled runs, the recorded results nor the action codes. There is no inspection query, no inspection mutation and no inspection webhook event.
  • Users, roles and teams — you cannot list, create or deactivate a user through this API. User provisioning happens in the app; Enterprise tenants can automate it with SCIM, which is a separate mechanism and not part of this API.
  • Projects — no query and no mutation.
  • Purchase requisitions — no query and no mutation. Purchase orders are readable (purchaseOrders, purchaseOrder), your ERP can write its reference back (setPurchaseOrderErpReference) and the purchase_order.* webhooks announce them, but an order cannot be created, edited or received through the API: that stays in the app.
  • Files, photos and attachments — you cannot upload a document or a photo, and you cannot read one back. Work order photos, part images and inspection evidence all sit outside the API. A URL that appears in a response, such as a part's imageUrl, is not an upload path.
  • Preventive maintenance schedules, time logs, tasks and checklists — not exposed.

Important:

Scope your build against this list, not against what the product does in a browser. FreeMaint's own web and mobile apps run on an internal REST API that an API key cannot reach, so a feature being visible on screen is not evidence that an integration can drive it.

Name
A human label — 'Oracle ERP bridge', 'Line 2 kiosk'. Issue one key per consuming system so you can revoke one without disturbing the others.
Access level
READ, READ_STOCK or FULL, an ordered hierarchy on top of the owner's permissions. READ runs queries only and is the default. READ_STOCK adds the adjustStock mutation. FULL allows every operation the owner's role allows, including create, update and delete — and the energy ingest endpoint when the owner may create energy readings.
Expiry
An optional expiresAt. Past that moment the key stops authenticating. Leave it empty for a permanent service integration; set it for a contractor or a pilot.
Revocation
isActive is a per-key switch that takes effect within seconds. The row is kept for audit, so a revoked key stays traceable rather than vanishing.
Last used
lastUsedAt records the most recent authenticated call — enough to spot a key nobody uses any more.
Owner
The person the key acts as. Every call carries their permissions and their data visibility, and their name signs what the key writes. For an ERP, create a dedicated user — "ERP integration" — with a role covering exactly what the bridge needs, and give it the key.

What an API key gives you

The raw key is shown once, at creation, and is never recoverable afterwards: only a SHA-256 hash is stored, along with the 6 characters that follow fmk_ so the list can identify the key on screen.

Name
A human label — 'Oracle ERP bridge', 'Line 2 kiosk'. Issue one key per consuming system so you can revoke one without disturbing the others.
Access level
READ, READ_STOCK or FULL, an ordered hierarchy on top of the owner's permissions. READ runs queries only and is the default. READ_STOCK adds the adjustStock mutation. FULL allows every operation the owner's role allows, including create, update and delete — and the energy ingest endpoint when the owner may create energy readings.
Expiry
An optional expiresAt. Past that moment the key stops authenticating. Leave it empty for a permanent service integration; set it for a contractor or a pilot.
Revocation
isActive is a per-key switch that takes effect within seconds. The row is kept for audit, so a revoked key stays traceable rather than vanishing.
Last used
lastUsedAt records the most recent authenticated call — enough to spot a key nobody uses any more.
Owner
The person the key acts as. Every call carries their permissions and their data visibility, and their name signs what the key writes. For an ERP, create a dedicated user — "ERP integration" — with a role covering exactly what the bridge needs, and give it the key.

What an API key does not give you

These are real limits of the current implementation, not omissions from this page. If your security review depends on any of them, design around them now rather than after the build.

  • No per-key module list. A key reads and writes what its owner's role allows — to narrow it, give it to an owner whose role is narrower. On top of that only the three access levels apply: you cannot list, on the key itself, the modules it may use.
  • No IP allowlist. A key is valid from anywhere on the internet. Treat it as a secret: a vault or your platform's secret store, never a repository, a shared drive or a support ticket.
  • No per-key usage log. Only lastUsedAt is kept. There is no per-key call history, no per-key quota and no record of which key performed which operation. The daily quota counter is per COMPANY and shared by every key, so one runaway integration can exhaust the quota of all the others.
  • No approval on someone's behalf. A key never approves a workflow step, even when its owner could: approvals stay a human act in the app.
  • One company per key. A key is bound to the company it was created in; a cross-tenant request fails with 401.

{ "event": "work_order.created", "timestamp": "2026-09-05T08:14:02.371Z", "data": { "work_order": { "id": 5521, "title": "Pump #3 leak", "status": "OPEN", "priority": "HIGH", "dueDate": "2026-09-08T00:00:00.000Z" } } }

The delivery envelope

Every GENERIC delivery is a POST whose body is the same three-key envelope: event, timestamp and data, where data is keyed by entity. Three headers travel with it. X-FreeMaint-Signature has the form t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of the exact string "<t>.<raw body>" keyed with that webhook's secret; X-FreeMaint-Event repeats the event name; X-FreeMaint-Delivery-Id identifies the delivery. Verify the signature against the RAW body, before parsing — re-serialising the JSON changes the bytes and the signature will not match.

Verify the key and read work orders in one call: curl -X POST https://freemaint.com/api/v1/graphql -H "Authorization: Bearer $FMK" -H "Content-Type: application/json" -d '{"query":"{ whoami { companyId tier } workOrders(limit: 5, status: \"OPEN\") { id reference title status priority dueDate } }"}'

What you can read

GraphQL exposes 16 read queries: whoami, workOrders, workOrder(id), assets, asset(id), assetDowntimes, meters(assetId), parts, part (by id or barcode), stockMovements, locations, vendors, customers, purchaseOrders, purchaseOrder(id | reference | erpReference) and floorPlans(locationId). Every list can be paged to the end: workOrders, assets, assetDowntimes, meters, parts, purchaseOrders, locations, vendors and customers all take limit (1 to 200) plus offset, and workOrders, assets, parts, purchaseOrders, locations, vendors and customers also take updatedSince for incremental syncs. The default page is 50 rows, except locations, vendors and customers, which keep their former default of 200. Every list is ordered with a tie-breaker on id, so a page boundary never skips or repeats a row. stockMovements is the stock ledger you reconcile from: it is read oldest first and paged with afterId — pass the id of the last movement you received — which stays exact while new movements are being written, and since lets you start a backfill from a date. Work orders in the recycle bin are never returned. purchaseOrders also filters by status and vendorId; amounts are in the company currency, which every order carries in its currency field.

Reliability and monitoring

A delivery is attempted 3 times with exponential backoff on a 2-second base — roughly 2s, then 4s, then 8s — and each attempt times out after 10 seconds. A 2xx marks it delivered. 5xx responses and network errors are retried. A 4xx is treated as a permanent refusal and is NOT retried at all: if your endpoint answers 400 or 401 because of a bug on your side, that delivery stops there and only a manual replay will recover it.

  • Every delivery is stored: the event, the exact payload sent, the status, the HTTP code you returned, the first ~500 characters of your response body, the attempt count and the error message.
  • Statuses are PENDING, DELIVERED, FAILED, RATE_LIMITED (the Starter cap of 100 deliveries per day was reached) and SKIPPED_TIER (the plan no longer entitles that destination).
  • A stored delivery can be replayed from the webhook's history. It re-sends the saved payload unchanged, so the business keys inside it stay identical and a receiver that deduplicates properly will not post twice.
  • Each webhook carries lastTriggeredAt, lastSuccessAt, lastFailureAt and a failureCount, which is enough for a health check without opening the full history.
  • The webhook list and its delivery log are readable on every plan, so an integration stopped by a downgrade is visible instead of silently dead.
  • Nothing alerts you when deliveries start failing — no email, no notification. If a silent outage would matter, poll the delivery log from your own monitoring.

Security

URLs are validated when you save the webhook, and the rules are strict on purpose: a webhook is an outbound request made by our servers, so a permissive one would be an SSRF vector.

  • Keys are stored as SHA-256 hashes — the raw value cannot be recovered from FreeMaint after creation.
  • Webhook URLs must use https. Plain http and every other scheme are refused.
  • Only ports 443 and 8443 are accepted.
  • Loopback, link-local and private-range hosts (127.x, 10.x, 192.168.x, 172.16–31.x, ::1 and the like) are refused: the receiver must be reachable from the public internet. A system that only exists inside your plant needs a public relay or a tunnel.
  • Credentials in the URL (https://user:pass@host) are refused.
  • Redirects are not followed — the endpoint you register is the endpoint that receives the POST.
  • Each webhook has its own signing secret and there is a rotate-secret action. Rotation takes effect immediately, so deploy the new secret on your side in the same window.
  • SLACK and TEAMS destinations receive a platform-formatted message and NO FreeMaint HMAC header: for those, the secrecy of the URL is the only authentication. Use GENERIC for anything you need to verify cryptographically.

One key, three doors

An API key (it starts with fmk_) is created in Company Settings > API (/company-settings/api) by an admin, or by a person for themself under Profile > Security > API keys, and sent as Authorization: Bearer fmk_... . It authenticates exactly three things: the GraphQL endpoint at POST /api/v1/graphql, the energy ingest endpoint, and /public-api/whoami and /public-api/ping. It does NOT authenticate the REST routes under /api/v1/{resource} — those are the application's own API and require a user session (JWT). Build your integration on GraphQL: that is the surface a key reaches.

GraphQL API and API keys
Business and above (API_GRAPHQL). Core and Starter cannot create a key at all.
Daily quota
5,000 calls per day on Business, 100,000 on Enterprise. Counted per company and per operation, resetting at 00:00 UTC — grouping operations into one HTTP request saves round-trips, never quota.
Webhooks
Starter and above, capped at 100 deliveries per day; unlimited from Business (WEBHOOKS_100_DAY, then WEBHOOKS_UNLIMITED).
Slack and Teams destinations
Enterprise (INTEGRATIONS_SLACK_TEAMS). A GENERIC webhook is available from Starter.

What you need to be on

Two independent gates: one for the API, one for webhooks. A tenant can have webhooks without the API.

GraphQL API and API keys
Business and above (API_GRAPHQL). Core and Starter cannot create a key at all.
Daily quota
5,000 calls per day on Business, 100,000 on Enterprise. Counted per company and per operation, resetting at 00:00 UTC — grouping operations into one HTTP request saves round-trips, never quota.
Webhooks
Starter and above, capped at 100 deliveries per day; unlimited from Business (WEBHOOKS_100_DAY, then WEBHOOKS_UNLIMITED).
Slack and Teams destinations
Enterprise (INTEGRATIONS_SLACK_TEAMS). A GENERIC webhook is available from Starter.

Planning something that runs into one of the gaps above? Tell us what you are building at contact@freemaint.com. Which capability ships next is decided by what customers are actually blocked on.

Mirror consumption into an ERP
Subscribe to part.consumed. quantity is signed — negative when issued to the job, positive when returned — and the payload carries a stock_movement_id that stays stable across retries and replays, so use it as your idempotency key. The adjustStock mutation deliberately does NOT raise this event, which is what stops your own writes echoing back to you in a loop.
Push stock corrections in
Call adjustStock with a signed delta from your side. A READ_STOCK key can do this without being able to create, update or delete anything, which makes it a tight fit for a one-way stock bridge.
Create work orders from another system
createWorkOrder from a SCADA alarm, a helpdesk ticket or an ERP breakdown notice. The work order receives its normal reference and raises work_order.created like any other.
Keep a parts catalogue in step
parts(updatedSince:) returns only what changed since a moment you choose. createPart and updatePart accept customReference — your own code for the part — and part(barcode:) then matches on it, so you can look a part up by the identifier your ERP already uses. It is not enforced unique; that is yours to guarantee.
Close the loop on completion
work_order.completed carries the work order and its previous status, which is enough to close a maintenance notification on the other side.

Patterns that work well today

These are the integrations the current surface supports cleanly, and the detail that makes each of them safe to run in production.

Mirror consumption into an ERP
Subscribe to part.consumed. quantity is signed — negative when issued to the job, positive when returned — and the payload carries a stock_movement_id that stays stable across retries and replays, so use it as your idempotency key. The adjustStock mutation deliberately does NOT raise this event, which is what stops your own writes echoing back to you in a loop.
Push stock corrections in
Call adjustStock with a signed delta from your side. A READ_STOCK key can do this without being able to create, update or delete anything, which makes it a tight fit for a one-way stock bridge.
Create work orders from another system
createWorkOrder from a SCADA alarm, a helpdesk ticket or an ERP breakdown notice. The work order receives its normal reference and raises work_order.created like any other.
Keep a parts catalogue in step
parts(updatedSince:) returns only what changed since a moment you choose. createPart and updatePart accept customReference — your own code for the part — and part(barcode:) then matches on it, so you can look a part up by the identifier your ERP already uses. It is not enforced unique; that is yours to guarantee.
Close the loop on completion
work_order.completed carries the work order and its previous status, which is enough to close a maintenance notification on the other side.

Warning:

There is no inspection event, and no defect or finding event. If your process is 'an inspection fails, my ERP reacts', nothing fires for it today. Plan for that gap explicitly instead of assuming a near-enough event exists.

What you can write

Twelve mutations: create, update and delete on work orders, assets and parts, plus adjustStock for a signed inventory movement. They run through the same services the application itself uses, so references (WO-…, ASS-…, PART-…), audit rows and notifications behave exactly as they do in the interface. For stock, prefer adjustStock: it writes a StockMovement audit row in the same transaction and refuses to take a quantity below zero, whereas updatePart(quantity) sets an absolute value and records the difference as an adjustment. On a part whose stock is tracked per location, pass locationId to adjustStock; updatePart(quantity) is refused there. The eleventh, addMeterReading, records a meter reading through the same service as the meter screen, so meter triggers and meter-based preventive maintenance fire exactly as they do for a reading typed in the app. The twelfth, setPurchaseOrderErpReference, stores your ERP's document number on a purchase order so the two records can be matched later with purchaseOrder(erpReference: …). It needs a FULL-access key and deliberately fires no webhook: your ERP never receives its own write back as an event.


FreeMaint CMMS

© 2026 FreeMaint. All rights reserved.