ERP & System Integration Guide

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

9 min read

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.

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.

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.

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.

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

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.

Webhooks โ€” the eight events

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

  • 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)

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.

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.

{ "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" } } }

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.

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.

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.

Tip

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.

Was this page helpful?