FreeMaint
FeaturesPricing
DocsGuides & API referenceBlogPlaybooks & product updatesComparisonFreeMaint vs the alternativesChangelogWhat's new
AffiliatesEarn recurring commissionServicesSetup, migration & training
Contact
ENSign inSign up

Product

FeaturesPricing

Resources

DocsBlogComparisonChangelog

Partnership

AffiliatesServices

Account

ContactSign in
FreeMaint

Free your maintenance

Product

FeaturesPricingComparisonChangelogSign up

Resources

BlogDocumentationResearch

Company

AboutServicesAffiliatesContact

Legal

PrivacyTermsRefund PolicyExport Compliance
Freemaint LLC ยฉ 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.
Service accounts
Business+ tier โ€” issue a non-expiring token tied to a specific role

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: 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.

Header
Authorization: Bearer <token>
Token TTL
Access token: 2 hours. Refresh token: 30 days.
Service accounts
Business+ tier โ€” issue a non-expiring token tied to a specific role

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 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.

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","description":"Visible to admins from Business tier upward."}
  2. {"title":"Click 'New key'","description":"Optionally set an expiration. 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: READ (queries only), READ_STOCK (queries + adjustStock โ€” ideal for a consume-only kiosk), FULL (default โ€” all operations)

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 11 read queries: whoami, workOrders, workOrder(id), assets, asset(id), parts (with lowStock filter), part (by id or barcode), floorPlans(locationId), locations, vendors, customers.

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 locationId dueDate } } Returns up to 200 rows; the default limit is 50.

Available filters

  • workOrders(limit, status) โ€” limit 1-200, status string filter
  • assets(limit) โ€” limit 1-200
  • 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 โ€” return up to 200 rows ordered by name
  • 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

Schema introspection

Introspection is enabled โ€” point Apollo Sandbox or GraphiQL at /api/v1/graphql with your Bearer token to explore the full schema interactively.

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.


GraphQL Mutations (Write)

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.

Attribution

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.

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

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.

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. 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.


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", "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.

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
  • 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.consumed โ€” a part was consumed on, or returned from, a work order
  • compliance.chain_divergence โ€” the audit hash-chain broke (Enterprise)
  • report.daily_summary โ€” the day's completed work, once a day at the hour you choose

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
  • 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.consumed โ€” a part was consumed on, or returned from, a work order; quantity is signed
  • compliance.chain_divergence โ€” the audit hash-chain broke (Enterprise)
  • report.daily_summary โ€” the day's completed work, once a day at the hour you choose

What the API does not cover at all

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.

  • 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 orders and purchase requisitions โ€” no query and no mutation. A procurement bridge cannot be built on this API today.
  • 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, meters, 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. READ runs queries only. READ_STOCK adds the adjustStock mutation and the energy ingest endpoint. FULL, the default, allows everything, including create, update and delete.
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.

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. READ runs queries only. READ_STOCK adds the adjustStock mutation and the energy ingest endpoint. FULL, the default, allows everything, including create, update and delete.
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.

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-module scoping. The three access levels are global. You cannot issue a key that reads parts but not work orders, nor one restricted to a single location, asset or department. A READ key reads everything the GraphQL schema exposes for your company.
  • 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 user identity. API calls carry no logged-in user, so mutations are attributed to the company's first admin. Your audit trail will show that person, not your ERP.
  • 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 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.

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 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.

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

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.