Build integrations with REST, GraphQL queries and mutations
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.
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: create it in Company Settings > API (/company-settings/api) and send Authorization: Bearer fmk_... . 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.
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 } }
The GraphQL endpoint is POST /api/v1/graphql. It supports the full Query/Mutation surface for the same resources plus relationship traversal. Use GraphQL when you would otherwise have to call several REST endpoints to assemble one screen โ for example, a dashboard tile that shows a work order with its asset details, parts used, and total labor cost.
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'
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.
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.
API keys are not available on Core or Starter.
URLs include /v1 โ when we introduce breaking changes (rare), we ship /v2 alongside and keep /v1 supported for at least 12 months before deprecation.
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).
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.
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.
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.
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.
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.
Monitor X-RateLimit-Remaining in your integration's dashboard so you can warn yourself before hitting the cap.
Fetch data with type-safe GraphQL queries
The GraphQL endpoint at /api/v1/graphql exposes 11 read queries: whoami, workOrders, workOrder(id), assets, asset(id), parts (with lowStock filter), part (by id or barcode), floorPlans(locationId), locations, vendors, customers.
POST https://freemaint.com/api/v1/graphql with Content-Type: application/json. Each query consumes 1 from your daily quota.
query { workOrders(limit: 10, status: "OPEN") { id title priority status assetId locationId dueDate } } Returns up to 200 rows; the default limit is 50.
Introspection is enabled โ point Apollo Sandbox or GraphiQL at /api/v1/graphql with your Bearer token to explore the full schema interactively.
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.
Create, update and delete records via GraphQL
From the Business tier, the GraphQL endpoint supports 10 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.
Public API requests don't have a logged-in user, so mutations are attributed to the first ADMIN of the company (or any active user as fallback). Audit logs always show a real human, never NULL.
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"}}
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.
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. Prefer it over updatePart(quantity), which sets an absolute value and records no stock history. 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.
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).
{ "event": "part.consumed", "timestamp": "2026-08-19T21:04:11.000Z", "data": { "part": { "id": 812, "name": "Bearing 6204", "part_number": "RLM-204", "reference": "PART-812", "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" } }
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.
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.
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.
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.
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();
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'])}})
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.
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 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.
The GraphQL schema contains work orders, assets, parts, 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.
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.
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.
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.
{ "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" } } }
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 } }"}'
GraphQL exposes 11 read queries: whoami, workOrders, workOrder(id), assets, asset(id), parts, part (by id or barcode), locations, vendors, customers and floorPlans(locationId). Read the paging rules closely, because they bite: parts is the ONLY list you can page through โ it takes limit (1 to 200, default 50) plus offset, and updatedSince for incremental syncs. workOrders and assets accept limit but no offset, so you can never reach past the 200 most recent. locations, vendors and customers take no arguments at all and return the first 200 by name. If your company holds more than 200 of any of those, the remainder is simply not reachable through the API.
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.
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.
An API key (it starts with fmk_) is created by an admin in Company Settings > API (/company-settings/api) 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.
Two independent gates: one for the API, one for webhooks. A tenant can have webhooks without the API.
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.
These are the integrations the current surface supports cleanly, and the detail that makes each of them safe to run in production.
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.
Ten 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) overwrites the value and records no history.
FreeMaint CMMS
ยฉ 2026 Freemaint LLC. All rights reserved.