A REST API with a published OpenAPI spec, signed webhooks, and an MCP server so an AI agent can work inside your account with exactly the permissions you give it. All of it is in the base plan — there is no enterprise tier to unlock.
app.flexonthejob.com/docs
OpenAPI documentMachine-readable contract. Generate a client in any language./openapi/v1.json
llms.txtPlain-text overview for LLMs; llms-full.txt has every field./llms.txt
MCP serverStreamable HTTP. Point Claude, Cursor or your own agent at it./mcp
Official SDKs (beta)Typed, thin clients over this API — TypeScript on npm and Python on PyPI.npm i @flexonthejob/api
flx_live_…) is shown once and stored only as a hash.GET /api/v1/modules returns the organization's active modules and the key's permissions, so your code can discover its own capabilities.Idempotency-Key so a retried request never creates a duplicate.Base URL: https://app.flexonthejob.com/api/v1. Every response is scoped to the organization that owns the key — there is no cross-tenant access, by design.
No account yet? Point the same calls at https://sandbox.flexonthejob.com/api/v1 with the public read-only key flx_live_YLJG6oxA6hOiJTT7ziyAOFs8nJAJfbRE, or the public write key flx_live_PPZ35t5mlrEPePjZl3yDzQeB7Gweq9GU (details; the demo data is reset every night at 08:00 UTC).
# 1. What can this key do?
curl https://app.flexonthejob.com/api/v1/modules \
-H "Authorization: Bearer flx_live_…"
# 2. First page of items, 50 at a time
curl "https://app.flexonthejob.com/api/v1/items?limit=50" \
-H "Authorization: Bearer flx_live_…"
# → { "items": [ … ], "nextCursor": "eyJpZCI6NTB9" }
# 3. Create a customer — safe to retry with the same key
curl -X POST https://app.flexonthejob.com/api/v1/customers \
-H "Authorization: Bearer flx_live_…" \
-H "Idempotency-Key: 7c1f2a4e-order-1042" \
-H "Content-Type: application/json" \
-d '{ "name": "Sunny Point Café", "email": "[email protected]",
"phone": "828-555-0142", "customerType": "Commercial" }'
# → 201 Created, Location: /api/v1/customers/57
A key is not a login. It is a named credential with its own permission set, its own rate limit and its own request log, and it can be revoked without touching anyone's password.
When you create a key you pick from the same permissions Flex uses for people — ViewJobs, ManageJobs, ViewInventory, ManageCustomers, ViewInvoices, ManageInvoices and so on. A key can only be granted permissions its creator holds. Each endpoint names the permission it needs; without it the call gets a 403.
Fixed one-minute windows: 120 requests/min per key by default (adjustable per key when you create it) and 600/min per organization. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window resets) so a client can pace itself; over the limit you get 429 with a Retry-After header in seconds. Reads and writes count the same.
The secret is shown once and stored as a salted PBKDF2 hash; a leaked database does not leak keys. Every request is logged against the key (method, path, status, timing), and Settings shows when each key was last used. Revoke a key and it stops working on the next request.
Give a key an end date when you create it — in 30, 60, 90 or 180 days, a year, or on a date you pick (valid through the end of that day, UTC) — or change it later with Edit. From then on the key gets 401 with a problem detail saying when it expired, and a WWW-Authenticate header with error="invalid_token". An expired key can be extended; the list flags keys that expire within 7 days.
Optionally limit a key to up to 50 IPv4 or IPv6 addresses or CIDR ranges (203.0.113.0/24, 2001:db8::/32). A call from anywhere else gets 403 naming the caller's address, never the list. Checked on every request to /api/v1 and /mcp, including cached lookups; the address is the connection's own, so a sent X-Forwarded-For can't fake it.
Every API and MCP request — key, method, path, status, timing, IP, and for MCP the method and tool name (never arguments or results) — is kept 90 days. Admins read it at Settings → Integrations → API activity with filters and a CSV download, or from code with GET /api/v1/activity and the MCP tool list_api_activity (ManageOrganizations). Refused calls from an expired or out-of-range key show up there too, with the reason.
| Concern | How it works |
|---|---|
| Authentication | Authorization: Bearer flx_live_… on every request. Missing, invalid or expired → 401 problem detail; a key used from outside its IP allow-list → 403. |
| Pagination | List endpoints return { "items": [...], "nextCursor": "…" }. Pass after=<nextCursor> for the next page and limit (1–200, default 50) to size it. nextCursor is null on the last page; cursors are opaque and stable. |
| CSV | Every list endpoint also answers in CSV: send Accept: text/csv or append ?format=csv. Same filters and paging — the next page's cursor comes back in the X-Next-Cursor header and as Link: <…>; rel="next", both absent on the last page. Columns are the JSON property names in order (nested objects flattened to parent.child, lists of scalars ;-joined), values exactly as in the JSON, UTF-8 with BOM, CRLF rows, RFC 4180 quoting. Errors stay application/problem+json. GET /modules and single-resource GETs are JSON only. See the scheduled export recipe. |
| Incremental sync | Record lists (customers, locations, jobs, items, an item's units, categories, reorder points, invoices, schedule groups, transfers, vendors, purchase and sales orders) accept updatedSince=<ISO-8601 UTC> and return only records created or modified at or after that instant; /schedule is a date window (from/to), /activity is oldest-first so its after cursor follows new rows, and /users, /roles, /users/removal-requests, /items/low-stock, an item's reorder points and an item's categories are small enough to pull whole. Store the time you started the last pull and pass it on the next one; pair with webhooks if you need sub-minute latency. |
| Idempotency | Every POST, PUT, PATCH and DELETE requires an Idempotency-Key header (any unique string, up to 128 characters). Replaying the same key with the same body returns the original response; the same key with a different body is rejected with 422. Keys are remembered for 24 hours per organization. |
| Caching | Single-resource and list GETs return a weak ETag. Send it back as If-None-Match to get 304 Not Modified with no body. A list's tag is per representation — the JSON and CSV tags of the same page differ — and list responses carry Vary: Accept. |
| Errors | RFC 7807 application/problem+json. 400 validation errors list per-field messages under errors; 401/403/404/409/422/429/500 carry title, detail and a traceId to quote to support. |
| Modules | Endpoints that belong to an optional module (scheduling, for example) answer 403 with a problem detail when the organization hasn't turned that module on. GET /api/v1/modules tells you in advance. |
| Formats | JSON, camelCase, enums as strings, timestamps ISO-8601 UTC, money as decimal numbers, ids as integers. |
The full reference with request and response schemas is at app.flexonthejob.com/docs. This is the map.
| Resource | Operations | Permission |
|---|---|---|
| Modules | GET /modules — active modules and the key's permissions. | any key |
| Customers | GET /customers · POST /customers · GET /customers/{id} · PUT /customers/{id} · DELETE /customers/{id} (soft delete; refused while jobs, invoices or sales orders are open) · GET /customers/{id}/locations · GET /customers/{id}/locations/{locationId} · POST /customers/{id}/locations · PATCH /customers/{id}/locations/{locationId} (only the fields you send; If-Match → 412 when stale) · DELETE /customers/{id}/locations/{locationId} (deactivates; 409 while a job uses it) — the customer's service addresses, same rules as the Addresses page: a customer's first address, or one marked mailing, becomes its mailing address | ViewCustomers / ManageCustomers |
| Jobs | GET /jobs · POST /jobs (estimate or draft, with material, labor and custom-charge lines) · GET /jobs/{id} · PATCH /jobs/{id} (edit a draft/estimate's type, description, notes, schedule, customer or address; status via /status) · POST /jobs/{id}/status · POST /jobs/{id}/assignees · POST /jobs/{id}/reschedule · POST /jobs/{id}/cancel (returns materials to stock) · POST /jobs/{id}/photos (attach a JPEG/PNG photo, up to 10 MB: multipart/form-data with a file part and optional caption; it lands on the job's timeline like a field-app photo and is included in Export All Data) · GET /jobs/{id}/photos (the job's photos, newest first) · GET /jobs/{id}/photos/{photoId}/content (the image bytes) · DELETE /jobs/{id}/photos/{photoId} (remove a photo from the job's timeline and its stored image) · POST /jobs/{id}/finalize (publish a draft estimate — the Publish Estimate button; or send publishImmediately: true on POST /jobs to create and publish in one call) · POST/PATCH/DELETE /jobs/{id}/items/{itemId}, …/labor/{laborId} and …/charges/{chargeId} (add, edit or remove a material, labor or custom-charge line — tier pricing, counter-sales stock and the finalized-invoice freeze handled server-side; each returns the updated job) | ViewJobs / ManageJobs |
| Schedule | GET /schedule — calendar events in a date range · GET /schedule-groups · GET /schedule-groups/{id} · POST /schedule-groups · PATCH /schedule-groups/{id} (only the fields you send; a parent that would make a cycle → 422) · DELETE /schedule-groups/{id} (soft delete; 409 while it has child groups) · PUT /schedule-groups/{id}/members/{userId} (add an active user, optional { "isLead": true }) · DELETE /schedule-groups/{id}/members/{userId} · PUT /schedule-groups/{id}/members ({ "userIds": [ … ], "leadIds": [ … ] } — replace the whole crew, the page's member picker; all or nothing) · POST /schedule-groups/reorder ({ "parentGroupId": 3, "ids": [ 8, 5, 9 ] } — display order of every group under one parent, each named exactly once) · GET /usersScheduling module | ViewJobs / ManageJobs |
| Locations | GET /locations · GET /locations/{id} — warehouses, vans and job sites, for the locationId fields of stock, transfer and receiving calls | ViewInventory |
| Items | GET /items — templates with pricing and total on-hand · GET /items/{id} — properties, price tiers, barcodes and per-location stock · POST /items — create a catalog item (name, optional description, category, starting price and cost; same defaults as adding one in the app; creates no stock) · PATCH /items/{id} — change name, description, category, price or cost, or the item's preferred vendor and that vendor's part number (preferredVendorId, 0 removes it; vendorPartNumber, "" clears it) — only the fields you send; If-Match for optimistic concurrency; fires item.updated · DELETE /items/{id} — deactivate (soft delete; ?cascadeChildren=true for a group with items) · POST /items/{id}/stock/adjust — add or write off whole units at a location (signed delta, optional reason; never goes negative) · POST /items/{id}/stock/move — move units between stock locations (the app's "Quick move") · POST /items/stock/batch — up to 100 adjustments and moves in one call, all or nothing ({ operations: [{ action: "Adjust" | "Move", itemId, … }] }; a failure names operations[i] and changes nothing) | ViewInventory / ManageInventory (description, preferredVendorId and vendorPartNumber also need ManageTemplates, as on the Item Templates page; 403 otherwise) |
| Reorder pointslow stock | GET /items/low-stock — every item below one of its reorder points (the app's Reorder → Thresholds), with onHand, minimumStock, suggestedOrderQuantity (the reorder quantity, or twice the minimum), cost and the vendor to order from (preferred vendor, else the vendor it was imported from); filter by vendorId (0 = no known vendor) and locationId. Computed on request — it raises no alert and fires no webhook · GET /items/{id}/reorder-points · PUT /items/{id}/reorder-points { "locationId": 3, "minimumStock": 10, "reorderQuantity": 24 } — create or update the item's reorder point for one location, or for all locations together when locationId is left out (201 created, 200 updated) · GET /reorder-points — every reorder point in the organization (itemId, locationId — 0 for the all-locations ones, isActive, updatedSince; also CSV) · DELETE /reorder-points/{id} — remove one (the Delete button on Thresholds; synced devices drop it too — send "isActive": false to the PUT to keep the rule but stop it) | ViewInventory / ManageInventory |
| Item unitsstock rows | GET /items/{id}/units (filter by locationId, updatedSince) · GET /items/{id}/units/{unitId} — each unit of stock with its status (Available, InTransit, AtJobSite, InBundle), location and its own property values · PUT /items/{id}/units/{unitId}/properties/{name} { "value": "SN-4471" } — set one unit's value, typed like item properties; the unit's copy is marked customized and its dependent calculated values are recomputed. Calculated values, Cost and Unit Price are refused (409) | ViewInventory / ManageInventory |
| Categories | GET /categories (search, parentCategoryId — 0 for top level, updatedSince) · GET /categories/{id} · POST /categories { "name": "Hardwood", "parentCategoryId": 1 } · PATCH /categories/{id} — rename, re-describe or move (parentCategoryId, 0 = top level; a move that would make a cycle → 422; names unique under the same parent → 409; Template Builder categories are read-only here) · DELETE /categories/{id} — only without child categories and not Template Builder-generated (409 otherwise); items whose primary category it was move to "No Category" · GET /items/{id}/categories — the categories an item is in and which are assigned by hand · POST /items/{id}/categories/{categoryId} · DELETE /items/{id}/categories/{categoryId} — assign or unassign by hand (parent categories follow) · GET /categories/{id}/properties — its own properties and the ones it inherits from parent categories · POST /categories/{id}/properties { "name": "Voltage", "dataType": "Dropdown", "options": [ "120V", "240V" ] } · PATCH /categories/{id}/properties/{propertyId} (only the fields you send) · DELETE /categories/{id}/properties/{propertyId} — the Item Categories → Manage Properties page: names unique regardless of case, Cost and Unit Price reserved, defaults checked against the type and options. Definitions only — items created in or moved to the category pick them up; existing items keep theirs | ViewInventory / ManageInventory |
| Item propertiescustom fields | GET /items/{id}/properties — every property of a catalog item with its type, options and value · PATCH /items/{id}/properties { "values": { "Color": "Red", "Warranty years": "5" } } — set several by name, all or nothing · PUT /items/{id}/properties/{propertyId}/value · POST /items/{id}/properties/{propertyId}/revert (back to the template default) · POST /items/{id}/properties (add an item-level property) · PATCH /items/{id}/properties/{propertyId} (rename, change type or dropdown options) · DELETE /items/{id}/properties/{propertyId}. Values are checked by type (numbers invariant decimal, true/false, dates yyyy-MM-dd, dropdown values from the options); Cost and Unit Price go through PATCH /items/{id}; calculated properties are read-only, and every calculated property that reads a changed value — on the item and on the stock units it flowed to — is recomputed in the same call. Every write returns the full list plus propagated, the number of stock units' copies it updated, and fires item.updated; a deleted property also disappears from synced desktop installs and devices. | ViewInventory / ManageInventory; definition changes and deleting a template property need ManageTemplates |
| Invoices | GET /invoices · GET /invoices/{id} (lines and payments) · POST /invoices/from-job (draft, same defaults as the Generate Invoice page) · POST /invoices (standalone invoice from lines, same defaults as the New Invoice page; created finalized) · PATCH /invoices/{id} (edit a draft's number, dates, terms, notes, payment instructions, tax rate — totals come from the job) · POST /invoices/{id}/refresh-from-job (recompute a draft's total from the job's current unbilled amount) · POST /invoices/{id}/mark-sent · POST /invoices/{id}/payments (record a payment and/or apply store credit; same rules as the Record Payment dialog) · POST /invoices/{id}/void (finalized invoices) · DELETE /invoices/{id} (delete a draft — the Delete Draft button; its down payment and store credit go back, finalized invoices are voided instead) | ViewInvoices / ManageInvoices |
| Purchase orders | GET /purchase-orders (filter by status, vendorId, updatedSince) · GET /purchase-orders/{id} (lines with ordered, received and remaining quantities) · POST /purchase-orders/{id}/receive — receive lines into stock with the same rules as receiving in the app: { lines: [{ lineId, quantity, locationId? }] }, only Delivered or PartialReceived orders (409 otherwise), every line checked before anything is written, over-receipt refused, stock created at the chosen location (default the order's receiving location) with cost from the order · POST /purchase-orders — create a draft order with its lines, same rules and PO numbering as the New Purchase Order page ({ vendorId, receivingLocationId, lines: [{ itemId, quantityOrdered, unitPrice }] }) · POST /purchase-orders/{id}/lines · POST /purchase-orders/{id}/mark-ordered, /mark-shipped, /mark-delivered, /cancel — the page's status buttons, so an order can go from draft to received without the app | ViewPurchaseOrders / ManagePurchaseOrders |
| Vendors | GET /vendors (search, includeInactive, updatedSince; CSV too) · GET /vendors/{id} · POST /vendors (name unique in the organization) · PATCH /vendors/{id} (only the fields you send; "isActive": false deactivates) | ViewPurchaseOrders / ManageVendors |
| Sales orders | GET /sales-orders (filter by status, customerId, updatedSince) · GET /sales-orders/{id} (lines with ordered, picked and shipped quantities) · POST /sales-orders — create an order with its lines, same numbering, tax and payment-terms defaults as the New Sales Order page ({ customerId, lines: [{ itemId, quantity, unitPrice }] }; lines for the same item merge, totals computed; unknown customer, address or item → 422, nothing written) · POST /sales-orders/{id}/fulfill — the Begin Fulfillment button: creates the pick transfer and reports the resulting status (Picking, or still Pending when stock does not cover every line; calling it again only covers what is still missing; 409 for complete or cancelled orders, when every line is already covered by a transfer, and when no stock is available) · POST /sales-orders/{id}/pick — Pick All, or given units per transfer line ({ "lines": [ { "transferItemId": 5120, "quantity": 2 } ] }, every line checked first) · POST /sales-orders/{id}/complete-pick (every line fully picked; the transfer goes in transit) · POST /sales-orders/{id}/ship ({ "carrier": "UPS", "trackingNumber": "…" } or { "withoutTracking": true }) · POST /sales-orders/{id}/deliver (stock moves to the customer and is consumed at a job-site or consuming location) · POST /sales-orders/{id}/complete · POST /sales-orders/{id}/cancel (delivered units come back, live transfers are cancelled). Each step takes an optional transferId — omitted, the order's only transfer in the right state is used — and returns the order's recalculated status and fulfillmentStatus · PATCH /sales-orders/{id} (order number, customer PO, payment terms, requested delivery date, notes, taxRate, shippingCost — only the fields you send; totals, payment status and draft invoices follow) · POST /sales-orders/{id}/lines · PATCH /sales-orders/{id}/lines/{lineId} (unitPrice, unitCost) · DELETE /sales-orders/{id}/lines/{lineId} (409 while the line is on a transfer) · PUT /sales-orders/{id}/lines/{lineId}/picked-quantity { "quantityPicked": 2 } (the Details page's picked-quantity editor: raising it claims more stock onto the live transfer, lowering it unpicks and releases units) · POST /sales-orders/{id}/lines/{lineId}/pick, POST …/lines/pick-all, POST …/scan { "barcode": "…" } (the Fulfill page's counters — order lines only, no stock moves; 409 on complete or cancelled orders) · POST /sales-orders/{id}/transfers (another transfer for what is still uncovered) · GET /sales-orders/{id}/returnable-items and POST /sales-orders/{id}/returns { "reason": "Wrong size", "stockUnitIds": [ 881 ] } (or "all": true, or a transferId; restocked at the source or a return location, totals and invoices reduced, overpayment becomes store credit; one transaction). Invoicing a sales order stays in the app. | ViewSalesOrders / ManageSalesOrders; returns also ManageReturns |
| Transfers | GET /transfers (filter by jobId, status, approvalStatus — ?approvalStatus=Pending&status=Pending is the approval queue) · GET /transfers/{id} (lines and approval state) · POST /jobs/{id}/transfer — create the job-site transfer (the Job Details "Create Transfer" button, same rules: not in counter-sales mode, job Estimated, Scheduled or In Progress; picks stock and reserves the job's lines) · POST /transfers — a standalone transfer between two locations ({ "sourceLocationId": 1, "destinationLocationId": 4, "lines": [ { "itemId": 12, "quantity": 3 } ] }; the server picks the units, and a transfer over the approval threshold waits for approval) · POST /transfers/{id}/pick → /start → /complete (optional failedLines for what didn't arrive) or /fail — the Transfers page's buttons, standalone transfers only · POST /transfers/{id}/cancel (job-site transfers, or standalone ones while Draft, or Pending and not awaiting approval; releases the stock) · POST /transfers/{id}/approve · POST /transfers/{id}/reject (transfers awaiting approval only; reject needs a reason) · POST /transfers/{id}/submit (Draft → Pending; over the approval threshold it waits for approval, as a new transfer does) · POST /transfers/{id}/lines/{lineId}/pick and …/unpick ({ "quantity": 2 }, or no body for all) · PATCH /transfers/{id}/lines/{lineId} { "quantity": 1 } (reduce only; the dropped units are released) · POST /transfers/{id}/scan { "barcode": "…" } — these while the transfer is Pending and not awaiting approval · POST /transfers/{id}/waypoints/{waypointId}/arrive, /deliver, /depart and /skip (the Waypoint Delivery page, on a transfer in transit; stock moves to the stop on arrival; a skipped stop's lines go to a later stop, the destination or another location) · POST /transfers/{id}/lines/{lineId}/redirect { "destinationType": "Location", "destinationId": 7 } (send one line to a waypoint, the destination or another location). The route steps are for standalone transfers; GET /transfers/{id} lists the waypoints and each line's stop. Sales-order transfers move with the /sales-orders steps. | ManageTransfers (reads too, as in the app) |
| Users and roles | GET /users/{id} · POST /users ({ email, firstName, lastName, password, role?, customRoleId? }; 202 when the account exists on the server but hasn't reached this host yet) · PATCH /users/{id} (name, role, isActive, the full customRoleIds set) · POST /users/{id}/deactivate · POST /users/{id}/reactivate · GET /roles (built-in and custom, with their permissions) · GET /roles/{id} · POST /roles · PATCH /roles/{id} · DELETE /roles/{id} (custom roles; a delete is refused with 409 while accounts hold the role, and ?unassignUsers=true removes it from them first). The key's creator must be an Organization Admin, can't change their own account, must outrank the account they change, may give it any role up to their own (an Organization Admin can promote a Manager or User to Organization Admin), and can never assign System Admin; whatever a call grants must be held by both the key and its creator (403 names what's missing). Password resets stay in the app. An Organization Admin is never demoted or deactivated directly — GET /users/removal-requests · POST /users/{id}/removal-requests { "action": "Demote", "newRole": "Manager", "reason": "…" } (or "Deactivate") gives them 14 days' notice they can contest, unless they approve; POST …/removal-requests/{requestId}/approve and /contest (by that admin), /cancel (by the requester). A request for the key creator's own account steps down at once, and the organization's last active Organization Admin can never be removed. | ManageUsers; role writes ManageRoles |
| Activity | GET /activity — the organization's API and MCP request log, oldest first so after follows new rows; filter by since, until, apiKeyId, statusClass (4 for 4xx), search (path, MCP method or tool). Also CSV. See activity log. | ManageOrganizations |
Paths are relative to https://app.flexonthejob.com/api/v1. Record lists accept updatedSince for incremental sync, and every list endpoint answers in CSV with Accept: text/csv or ?format=csv (recipe). What's next is on the roadmap.
Subscribe a URL to the events you care about under Settings → Integrations → Webhooks. Flex POSTs a JSON envelope, signs it, retries on failure and shows you every delivery attempt.
Sandbox note: the public sandbox lets you create, list and delete subscriptions, but it does not deliver webhooks — delivery is switched off there on purpose, so the shared demo host can't be turned into a relay that POSTs to arbitrary endpoints. Events still fire and each delivery is logged as skipped with the reason (0 attempts), just like an automation run — nothing is sent. The same applies to automations: a rule's conditions are still evaluated and each match is logged, but its action isn't carried out on the sandbox (the run is recorded as skipped) — for the same reason it holds copies of real customers' rules. To see a real delivery or automation run, use a trial or production account.
| Event | Fires when |
|---|---|
job.created | A job or estimate is created — from the API, the web app or a synced device. |
job.status_changed | A job moves to another status; data.previousStatus is included. |
job.scheduled | A job gets a start time or is rescheduled. |
job.assigned | The crew on a job changes. |
job.completed | A job reaches a completed status. |
invoice.created | An invoice is created. |
invoice.sent | An invoice leaves Draft (finalized or marked sent), or is created already finalized. |
invoice.paid | An invoice is paid in full. |
invoice.deleted | A draft invoice is deleted — from the web app, DELETE /api/v1/invoices/{id}, MCP delete_draft_invoice, or a synced device regenerating its draft. data is the invoice as it was (status Draft); its id no longer resolves. Finalized invoices are never deleted (they are voided). |
customer.created | A customer is created. |
transfer.created | An inventory transfer is created — a job-site transfer (API, MCP, app or device), a standalone transfer, or a direct/counter sale. Sent once its lines are saved; data.lines lists the reserved item ids and quantities. |
transfer.completed | An inventory transfer is delivered — to a location, a job site, or as a direct/counter sale. data.lines lists item ids and quantities. |
transfer.cancelled | An inventory transfer is cancelled — from a job, the Transfers pages or the API, or because its approval was rejected. |
inventory.low_stock | Stock for an item drops below its configured minimum and a new alert is raised (once per alert, not per unit). |
item.updated | An existing item's name, description, category, unit price, cost, preferred vendor or vendor part number changes, it is deleted/restored, one of its custom fields is added, removed or changed, or a custom-field value changes on one of its stock units. data.changedFields says which; data.changedProperties and data.changedUnitProperties name the custom fields. One event per item per save. |
purchase_order.created | A purchase order is created (any status). |
purchase_order.status_changed | A purchase order moves to another status (Draft → Ordered → Shipped → Received, or Cancelled). data.previousStatus is the status it left. |
sales_order.created | A sales order is created. |
sales_order.status_changed | A sales order moves to another status (Pending → Picking → Packed → Shipped → Delivered, Complete or Cancelled). data.previousStatus is the status it left. |
vendor.created | A vendor is created. |
vendor.updated | A vendor's details change (name, contact, website, default shipping company, or active/inactive). |
X-Flex-Signature-V2: t=<unix seconds>,v1=<hex> — HMAC-SHA256 of <t>.<raw body>, keyed with the subscription secret. Reject a t more than 5 minutes from your clock so a captured request can't be replayed. The older body-only X-Flex-Signature: sha256=<hex> is still sent.X-Flex-Delivery is the same on every retry of one delivery — ignore ids you've already processed. X-Flex-Event names the event.2xx counts as delivered.Content-Type: application/json
X-Flex-Event: job.status_changed
X-Flex-Delivery: 48213
X-Flex-Timestamp: 1790000000
X-Flex-Signature-V2: t=1790000000,v1=9b2e…4d10
X-Flex-Signature: sha256=3f7a…c21e
{
"event": "job.status_changed",
"occurredAt": "2026-09-20T14:03:11Z",
"organizationId": 13,
"data": {
"id": 4,
"jobNumber": "JOB-00004",
"customerId": 9,
"status": "In Progress",
"previousStatus": "Scheduled",
"scheduledDate": "2026-09-20T13:30:00Z",
"assigneeUserIds": ["u_luis", "u_kelsey"]
}
}
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const t = /(?:^|,)t=(\d+)/.exec(header)?.[1];
const v1 = /(?:^|,)v1=([0-9a-f]+)/.exec(header)?.[1];
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay
const expected = createHmac("sha256", secret)
.update(t + ".").update(rawBody).digest("hex");
return v1.length === expected.length &&
timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
// header = X-Flex-Signature-V2; raw bytes, before JSON parsing.
// Or: import { verifyWebhook } from "@flexonthejob/api/webhooks"
Every list endpoint answers in CSV — send Accept: text/csv or add ?format=csv. Pair it with updatedSince (on the record lists — customers, locations, jobs, items, item units, categories, reorder points, invoices, schedule groups, transfers, vendors, purchase and sales orders) and a cron job or scheduled task and you have a nightly delta export into a spreadsheet, a warehouse or your accountant's inbox, with nothing beyond the shell.
X-Next-Cursor header and as Link: <…>; rel="next". Both are absent on the last page, so "follow Link until it stops" is the whole loop.parent.child. CRLF rows, RFC 4180 quoting; a text cell starting with = + - @ gets a leading apostrophe so a spreadsheet won't run it.# Every job changed since the last run, as one CSV file.
KEY="flx_live_…"
BASE="https://app.flexonthejob.com/api/v1"
SINCE=$(cat .last-run 2>/dev/null || echo "1970-01-01T00:00:00Z")
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
URL="$BASE/jobs?updatedSince=$SINCE&limit=200"
OUT="jobs-$(date -u +%F).csv"; : > "$OUT"
while [ -n "$URL" ]; do
H=$(mktemp)
if [ -s "$OUT" ]; then # later pages: drop the repeated header row
curl -sSf -D "$H" -H "Authorization: Bearer $KEY" -H "Accept: text/csv" "$URL" | tail -n +2 >> "$OUT"
else
curl -sSf -D "$H" -H "Authorization: Bearer $KEY" -H "Accept: text/csv" "$URL" >> "$OUT"
fi
URL=$(tr -d '\r' < "$H" | sed -n 's/^[Ll]ink: <\(.*\)>; rel="next".*/\1/p')
done
echo "$NOW" > .last-run
# crontab: 15 2 * * * /opt/flex/export-jobs.sh
# Every job changed since the last run, as one CSV file.
$Key = "flx_live_…"
$Base = "https://app.flexonthejob.com/api/v1"
$Since = if (Test-Path .last-run) { (Get-Content .last-run -Raw).Trim() } else { "1970-01-01T00:00:00Z" }
$Now = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
$Out = "jobs-$([DateTime]::UtcNow.ToString('yyyy-MM-dd')).csv"
$Query = "updatedSince=$Since&limit=200&format=csv"
$Url = "$Base/jobs?$Query"
$First = $true
while ($Url) {
$r = Invoke-WebRequest -Uri $Url -Headers @{ Authorization = "Bearer $Key" }
$lines = $r.Content -split "`r`n" | Where-Object { $_ -ne "" }
if (-not $First) { $lines = $lines | Select-Object -Skip 1 } # drop the repeated header row
$lines | Add-Content -Path $Out -Encoding utf8
$First = $false
$next = $r.Headers["X-Next-Cursor"] | Select-Object -First 1
$Url = if ($next) { "$Base/jobs?$Query&after=$([Uri]::EscapeDataString($next))" } else { $null }
}
Set-Content .last-run $Now
# Task Scheduler: pwsh -File C:\flex\export-jobs.ps1, daily at 02:15
Flex speaks the Model Context Protocol natively. Connect Claude, Cursor, or any MCP client to https://app.flexonthejob.com/mcp with an API key and it gets 139 typed tools for the same operations as the REST API, 13 ready-made prompts and 2 resources — under the same permissions, the same rate limits and the same request log.
Authorization: Bearer flx_live_… header. No key, no tools.get_org_capabilities first; it reports the active modules and the key's permissions so the agent knows what will succeed./api/v1 operation has an MCP tool except webhook subscriptions, which you manage with /api/v1/webhooks or Settings → Integrations. Creating a subscription returns its signing secret, which must not end up in an AI transcript, and an agent should not be able to set up an outbound data feed to a URL of its choosing: a prompt-injected agent could otherwise send your jobs and invoices anywhere. Receivers are HTTP endpoints (Zapier, Make, your server), not chat sessions.| Tools | Needs |
|---|---|
get_org_capabilities | any key |
list_customers · get_customer · create_customer · update_customer · delete_customer · list_customer_locations · create_customer_location · update_customer_location · delete_customer_location | ViewCustomers / ManageCustomers |
list_jobs · get_job · create_job · update_job · update_job_status · cancel_job · finalize_estimate | ViewJobs / ManageJobs |
add_job_item · update_job_item · remove_job_item · add_job_labor · update_job_labor · remove_job_labor · add_job_charge · update_job_charge · remove_job_charge | ManageJobs |
list_job_photos · get_job_photo · add_job_photo · delete_job_photo | ViewJobs / ManageJobs |
reschedule_job · assign_job · get_schedule · list_schedule_groups · create_schedule_group · update_schedule_group · delete_schedule_group · set_schedule_group_member · remove_schedule_group_member · set_schedule_group_members · reorder_schedule_groups | ManageJobs / ViewJobs + Scheduling |
list_items · get_item · create_item · update_item · adjust_stock · move_stock · apply_stock_batch · deactivate_item | ViewInventory / ManageInventory (description, preferredVendorId and vendorPartNumber also need ManageTemplates) |
get_item_properties · set_item_property · set_item_properties · revert_item_property · add_item_property · update_item_property · delete_item_property | ViewInventory / ManageInventory (update_item_property and deleting a template property: ManageTemplates) |
list_item_units · set_item_unit_property | ViewInventory / ManageInventory |
list_categories · create_category · update_category · delete_category · get_item_categories · assign_item_category · remove_item_category · get_category_properties · add_category_property · update_category_property · delete_category_property | ViewInventory / ManageInventory |
list_low_stock · get_reorder_points · list_reorder_points · set_reorder_point · delete_reorder_point | ViewInventory / ManageInventory |
list_locations | ViewInventory |
list_labor_rates | ViewJobs |
list_transfers · get_transfer · create_job_transfer · create_transfer · submit_transfer · pick_transfer · pick_transfer_line · set_transfer_line_quantity · scan_transfer_barcode · start_transfer · advance_transfer_waypoint (arrive, deliver, depart, skip) · redirect_transfer_line · complete_transfer · fail_transfer · cancel_transfer · approve_transfer · reject_transfer | ManageTransfers |
list_purchase_orders · get_purchase_order · create_purchase_order · add_purchase_order_line · advance_purchase_order · receive_purchase_order | ViewPurchaseOrders / ManagePurchaseOrders |
list_vendors · create_vendor · update_vendor | ViewPurchaseOrders / ManageVendors |
list_sales_orders · get_sales_order · create_sales_order · fulfill_sales_order · advance_sales_order (pick, complete picking, ship, deliver, complete, cancel) · update_sales_order · add_sales_order_lines · update_sales_order_line · remove_sales_order_line · pick_sales_order_lines (picked quantity, one unit, all, or a barcode) · create_sales_order_transfer | ViewSalesOrders / ManageSalesOrders |
get_sales_order_returnable_items · return_sales_order_items | ViewSalesOrders / ManageSalesOrders, plus ManageReturns |
list_invoices · get_invoice · create_invoice_from_job · create_invoice · update_invoice · refresh_invoice_from_job · mark_invoice_sent · record_payment · void_invoice · delete_draft_invoice | ViewInvoices / ManageInvoices |
list_users · get_user · list_roles · create_user · update_user · list_admin_removal_requests · request_admin_removal · approve_admin_removal · contest_admin_removal · cancel_admin_removal | ManageUsers |
create_role · update_role · delete_role | ManageRoles |
list_api_activity | ManageOrganizations |
Resources: flex://org/modules · flex://job/{id} | any key / ViewJobs |
claude mcp add flex --transport http \
https://app.flexonthejob.com/mcp \
--header "Authorization: Bearer flx_live_…"
{
"mcpServers": {
"flex": {
"type": "http",
"url": "https://app.flexonthejob.com/mcp",
"headers": { "Authorization": "Bearer flx_live_…" }
}
}
}
“What's scheduled for the Service crew next week, and which of those jobs still has a pending stock transfer?” — the agent calls get_schedule, then get_job per result, and never sees anything the key wasn't granted.
Give an agent a read-only key first. Add ManageJobs when you want it to reschedule or assign.
The MCP server ships these as prompts: pick one in your client (prompts/list, prompts/get) and the agent gets a step-by-step plan that uses only the tools above. Each one starts with get_org_capabilities, says which permission or module to grant if something is missing, and shows you what it is about to change and waits for a yes before any write. A prompt is just text — it runs nothing on its own. Arguments are optional.
| Prompt | What it does | Needs |
|---|---|---|
reorder_below_minimum | Finds items below their reorder points (list_low_stock) — and items with no reorder point below a flat minimum, default 5 — and drafts one purchase order per vendor — the item's preferred vendor, else the vendor it was imported from or last ordered from. Offers to save a vendor you pick on the item and to add a reorder point. Optional vendorId, receivingLocationId. | ViewInventory, ViewPurchaseOrders, ManagePurchaseOrders; saving a vendor on an item also ManageInventory + ManageTemplates |
receive_delivery | Confirms what arrived line by line, then receives it into stock in one all-or-nothing call. Optional purchaseOrderId or vendorName. | ViewPurchaseOrders, ManagePurchaseOrders |
cycle_count | Compares counted quantities at one location with Flex and applies every correction as one batch. Optional locationId, counts as free text. | ViewInventory, ManageInventory |
restock_vans | Tops up each vehicle to its par levels from a warehouse as one batch of moves. Optional sourceLocationId, parLevels as free text. | ViewInventory, ManageInventory |
invoice_completed_jobs | Lists completed jobs that haven't been billed, creates draft invoices for the ones you confirm, and finalizes (mark_invoice_sent) the drafts you approve. Optional completedSince. | ViewJobs, ViewInvoices, ManageInvoices |
morning_dispatch_summary | Summarizes a day's schedule by crew and flags jobs with no crew, materials not sent or carried over. Read-only unless you ask for a fix. Optional date. | ViewJobs + Scheduling |
customer_balance_followup | Lists customers with overdue unpaid invoices (and overdue drafts that were never sent), drafts a reminder for each, and finalizes drafts or records payments you report. Optional minimumBalance, daysOverdue. | ViewInvoices, ViewCustomers; ManageInvoices to finalize or record payments |
review_pending_transfers | Walks the transfer approval queue and approves or rejects each one as you decide. | ManageTransfers |
fulfill_ready_sales_orders | Walks sales orders that are ready (Pending or Picking) and moves each — create pick transfer, pick, pack, ship, deliver — pausing before each shipment. Optional customerId. | ViewSalesOrders, ManageSalesOrders |
stock_valuation_report | Values stock on hand by category and by location, at cost and at retail. Read-only. Optional categoryId, locationId. | ViewInventory |
end_of_day_summary | Summarizes the day: jobs completed, invoices raised and paid, transfers delivered, new low-stock alerts. Read-only. Optional date. | ViewJobs; more with ViewInvoices, ViewInventory |
onboard_customer | Creates a customer from a few details (checking for duplicates) and, if you want, books their first job. Optional details as free text. | ManageCustomers; ManageJobs to book a job |
update_costs_from_vendor | Updates item costs (and optionally prices) from a vendor price list, matching names to items and confirming every change. Optional priceList, vendorId. | ViewInventory, ManageInventory |
Prompt names and arguments follow the same stability rule as tools. Want the same flow without an agent? Every step is a REST call listed under Endpoints.
/api/v1 is additive-onlyWe add endpoints, fields, enum values and webhook events. We do not rename or remove fields, change types, or change the meaning of an existing value within v1. Treat unknown fields and unknown event names as ignorable and you will never break on a release.
If we ever need to break something it ships as /api/v2 alongside v1. The old version keeps working for at least 12 months after the new one is announced, with the deprecation date in the docs, in llms.txt and by email to every organization that holds an active key.
Event names and envelope fields are append-only. MCP tool names and parameters are stable; new optional parameters may appear. The OpenAPI document is generated from the running code, so it is never out of date with what the server actually does.
Changes are listed in the API changelog (Atom feed); live availability is on status.flexonthejob.com. Questions about a planned integration: [email protected].
No. API keys, webhooks and the MCP server are in the base plan at every term. There is no developer tier, no per-call fee and no seat consumed by a key.
Our standing commitment is no: everything a person can do in the Flex web app is meant to be doable over the API and MCP, and we treat a gap as a bug to close, not a tier to sell. Both hosts run the same services, so a new action shows up in code as it ships. A few authoring and document tasks are the last to move — building calculated properties, generating invoice and report PDFs and their emails, resetting passwords, and minting API keys stay web-only for now — while everything else, including every read and write in this reference, is in the API and MCP. Hit something you can't reach from code? Tell us at [email protected] and we'll close it.
Yes, and you don't need an account for it. https://sandbox.flexonthejob.com runs the same build as production against a demo organization — Copperline Heating & Air, a fictional HVAC company with technicians, customers, jobs, invoices and a few hundred stocked parts — with email and webhook delivery switched off. Its /openapi/v1.json is production's document with the servers list in the other order (each host lists itself first), so the two files hash differently while every path and schema is identical; GET /health on either host names the exact build. A shared read-only key is published for it — flx_live_YLJG6oxA6hOiJTT7ziyAOFs8nJAJfbRE — holding ViewInventory, ViewTemplates, ViewJobs, ViewCustomers and ViewInvoices at 30 requests/minute. Every GET on /api/v1 and every read tool on /mcp works with it except transfers, vendors, purchase orders, sales orders (and their returnable items), a single user (GET /users/{id}), roles and admin removal requests, and the activity log (they need ManageTransfers / ViewPurchaseOrders / ViewSalesOrders / ManageReturns / ManageUsers / ManageOrganizations, which it does not hold) — the GET /users list does work, since it needs only ViewJobs and the demo organization's scheduling module; writes return 403. A shared write key is published too — flx_live_PPZ35t5mlrEPePjZl3yDzQeB7Gweq9GU — holding the same reads plus ManageInventory, ManageTemplates, ManageCustomers, ManageJobs, ManageInvoices, ManageTransfers, ViewSalesOrders/ManageSalesOrders and ViewPurchaseOrders/ManagePurchaseOrders/ManageVendors at 30 requests/minute, so every write in this reference, REST or MCP, can be tried end to end except user and role administration (admin removal requests included) and sales-order returns (ManageReturns). It cannot manage users, roles, API keys or webhook subscriptions, or read the activity log, and nothing it does reaches a real person (email and webhook delivery are off). The demo organization is reset from its master copy every night at 08:00 UTC, so anything written there is gone the next morning; either key is rotated if it's abused. Your 30-day trial is a full account for anything you want to keep. Reviewers and auditors who want to test the admin-gated features rather than read them from the docs — required two-factor, API-key expiry, IP allow-lists, the activity log, full export, and user and role writes — can request admin sandbox access (a sandbox admin login and an admin-scoped key) privately at [email protected] or [email protected]; no admin key is published here. Scheduled export can only be exercised on a trial or production account, since its SFTP, S3 and email delivery is switched off on the shared sandbox for the same reason webhook and email delivery are.
Yes — typed, thin clients over this API, currently in beta: TypeScript/JavaScript npm install @flexonthejob/api and Python pip install flex-api. Both add Bearer auth, an automatic Idempotency-Key on writes, Retry-After handling and cursor pagination. Prefer to generate your own? The OpenAPI document at /openapi/v1.json is a standard OpenAPI 3 file — feed it to openapi-generator, Kiota, NSwag, openapi-typescript or whatever your stack prefers. The spec is the contract either way.
Yes — native apps for both Zapier and Make, built on the same public webhooks and write API documented here. Each has four instant triggers (job.completed, invoice.paid, inventory.low_stock, customer.created), Create Customer and Create Job actions, and a Find Customer search. The Zapier app is in open beta: add it to your Zapier account with this public invite link and connect it with a Flex API key. It is not yet in the Zapier catalog search, which requires a usage threshold. The Make app is available on request at [email protected].
Every list endpoint pages through the whole collection with after=<nextCursor> and answers in CSV with Accept: text/csv — the scheduled export recipe pulls a nightly delta into one file — and single-resource GETs return the full record. Invoices also export as CSV from the web app for QuickBooks. For everything at once, an admin downloads Settings → Export All Data: every table of the organization as JSON and CSV in one zip — including your photos and signatures under attachments/ — with a manifest and a data-dictionary.json describing every table and column (browse it at /data-dictionary). The same zip can go out on a schedule — daily or weekly to your SFTP server, an S3-compatible bucket, or as an emailed download link that expires after 7 days (Settings → Export All Data → Schedule exports; data policy).
No. The MCP server reuses the API key's permission set and organization scope exactly. A key with ViewJobs only cannot list customers, invoices or items, and no key can reach another organization.
Covered. transfer.created, transfer.completed and transfer.cancelled follow a transfer from creation to delivery to a location or job site (or cancellation), inventory.low_stock when an item drops below its configured minimum, and item.updated when an item's name, price, cost, vendor or custom fields change (including a value on one of its stock units). Purchasing and sales are covered too — purchase_order.created / purchase_order.status_changed, sales_order.created / sales_order.status_changed and vendor.created / vendor.updated. To poll instead, GET /items/low-stock lists everything below its reorder point without raising an alert. New events are always added as new names — existing subscriptions never change shape.
See the security page. Short version: TLS everywhere, hashed credentials, tenant isolation on every query, signed webhooks, and no SOC 2 report yet — we say so rather than imply otherwise.
Start a free trial, create a key, paste it into the docs. Ten minutes to your first request.