Developers

Everything in Flex,
reachable from code.

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.

OpenAPI 3 spec Interactive reference llms.txt for agents Per-key permissions
Quickstart

Two calls to a working integration.

  1. Create a key. In Flex, go to Settings → Integrations → API Keys. Tick the permissions the key needs and set its rate limit — and, if you like, an expiry date and the IP addresses it may be used from. The secret (flx_live_…) is shown once and stored only as a hash.
  2. Ask what you're allowed to do. GET /api/v1/modules returns the organization's active modules and the key's permissions, so your code can discover its own capabilities.
  3. Read, then write. Lists are cursor-paged; writes take an 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).

bash
# 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
Keys and permissions

Scoped by default.

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.

Per-key permissions

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.

Per-key rate limits

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.

Stored hashed, logged, revocable

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.

Expiry

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.

IP allow-lists

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.

Activity log

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.

Conventions

The same rules on every endpoint.

ConcernHow it works
AuthenticationAuthorization: Bearer flx_live_… on every request. Missing, invalid or expired → 401 problem detail; a key used from outside its IP allow-list → 403.
PaginationList 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.
CSVEvery 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 syncRecord 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.
IdempotencyEvery 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.
CachingSingle-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.
ErrorsRFC 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.
ModulesEndpoints 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.
FormatsJSON, camelCase, enums as strings, timestamps ISO-8601 UTC, money as decimal numbers, ids as integers.
Endpoints — v1

Customers, jobs, schedule, items, stock, transfers, purchase and sales orders, invoices, users.

The full reference with request and response schemas is at app.flexonthejob.com/docs. This is the map.

ResourceOperationsPermission
ModulesGET /modules — active modules and the key's permissions.any key
CustomersGET /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 addressViewCustomers / ManageCustomers
JobsGET /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
ScheduleGET /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 moduleViewJobs / ManageJobs
LocationsGET /locations · GET /locations/{id} — warehouses, vans and job sites, for the locationId fields of stock, transfer and receiving callsViewInventory
ItemsGET /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 stockGET /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 rowsGET /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
CategoriesGET /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 theirsViewInventory / ManageInventory
Item propertiescustom fieldsGET /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
InvoicesGET /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 ordersGET /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 appViewPurchaseOrders / ManagePurchaseOrders
VendorsGET /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 ordersGET /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
TransfersGET /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 rolesGET /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
ActivityGET /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.

Webhooks

Signed, retried, logged.

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.

EventFires when
job.createdA job or estimate is created — from the API, the web app or a synced device.
job.status_changedA job moves to another status; data.previousStatus is included.
job.scheduledA job gets a start time or is rescheduled.
job.assignedThe crew on a job changes.
job.completedA job reaches a completed status.
invoice.createdAn invoice is created.
invoice.sentAn invoice leaves Draft (finalized or marked sent), or is created already finalized.
invoice.paidAn invoice is paid in full.
invoice.deletedA 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.createdA customer is created.
transfer.createdAn 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.completedAn inventory transfer is delivered — to a location, a job site, or as a direct/counter sale. data.lines lists item ids and quantities.
transfer.cancelledAn inventory transfer is cancelled — from a job, the Transfers pages or the API, or because its approval was rejected.
inventory.low_stockStock for an item drops below its configured minimum and a new alert is raised (once per alert, not per unit).
item.updatedAn 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.createdA purchase order is created (any status).
purchase_order.status_changedA purchase order moves to another status (Draft → Ordered → Shipped → Received, or Cancelled). data.previousStatus is the status it left.
sales_order.createdA sales order is created.
sales_order.status_changedA sales order moves to another status (Pending → Picking → Packed → Shipped → Delivered, Complete or Cancelled). data.previousStatus is the status it left.
vendor.createdA vendor is created.
vendor.updatedA vendor's details change (name, contact, website, default shipping company, or active/inactive).
  • Signature: 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.
  • Deduplication: X-Flex-Delivery is the same on every retry of one delivery — ignore ids you've already processed. X-Flex-Event names the event.
  • Retries: up to 5 attempts with exponential backoff (2, 4, 8, 16, 32 s…), 10 s timeout per attempt. Any 2xx counts as delivered.
  • Auto-disable: after 20 consecutive failed deliveries the subscription is paused and shown as inactive; re-enable it when your endpoint is back.
  • Delivery log: every attempt — status code, timing, response — in Settings.
  • Endpoint rules: HTTPS only, redirects are never followed, private and link-local addresses are refused.
POST https://yourapp.example/hooks/flex
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"]
  }
}
verify.js — Node
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"
Recipe

Scheduled CSV export.

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.

  • Same paging as JSON: the next page's cursor comes back in the 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.
  • One header row per page: every page starts with the column names (and the UTF-8 BOM), so append pages after the first without their first line.
  • Columns are the JSON property names in declaration order; nested objects become parent.child. CRLF rows, RFC 4180 quoting; a text cell starting with = + - @ gets a leading apostrophe so a spreadsheet won't run it.
  • Use a read-only key. Webhooks tell you the moment something changes; the nightly file is the reconciliation.
export-jobs.sh — bash + curl
# 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
export-jobs.ps1 — PowerShell
# 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
MCP server

Let an agent do the paperwork.

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.

  • Streamable HTTP transport, stateless — no session to manage, works behind any proxy.
  • Auth is the same Authorization: Bearer flx_live_… header. No key, no tools.
  • Call get_org_capabilities first; it reports the active modules and the key's permissions so the agent knows what will succeed.
  • Nothing is inferred or hidden: every tool maps to a documented service operation, writes are visible in the job history like any other change, and every call — with the tool's name — is in the activity log.
  • Webhooks are REST-only, by design. Every /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.
ToolsNeeds
get_org_capabilitiesany key
list_customers · get_customer · create_customer · update_customer · delete_customer · list_customer_locations · create_customer_location · update_customer_location · delete_customer_locationViewCustomers / ManageCustomers
list_jobs · get_job · create_job · update_job · update_job_status · cancel_job · finalize_estimateViewJobs / 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_chargeManageJobs
list_job_photos · get_job_photo · add_job_photo · delete_job_photoViewJobs / 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_groupsManageJobs / ViewJobs + Scheduling
list_items · get_item · create_item · update_item · adjust_stock · move_stock · apply_stock_batch · deactivate_itemViewInventory / 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_propertyViewInventory / ManageInventory (update_item_property and deleting a template property: ManageTemplates)
list_item_units · set_item_unit_propertyViewInventory / 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_propertyViewInventory / ManageInventory
list_low_stock · get_reorder_points · list_reorder_points · set_reorder_point · delete_reorder_pointViewInventory / ManageInventory
list_locationsViewInventory
list_labor_ratesViewJobs
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_transferManageTransfers
list_purchase_orders · get_purchase_order · create_purchase_order · add_purchase_order_line · advance_purchase_order · receive_purchase_orderViewPurchaseOrders / ManagePurchaseOrders
list_vendors · create_vendor · update_vendorViewPurchaseOrders / 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_transferViewSalesOrders / ManageSalesOrders
get_sales_order_returnable_items · return_sales_order_itemsViewSalesOrders / 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_invoiceViewInvoices / 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_removalManageUsers
create_role · update_role · delete_roleManageRoles
list_api_activityManageOrganizations
Resources: flex://org/modules · flex://job/{id}any key / ViewJobs
Claude Code
claude mcp add flex --transport http \
  https://app.flexonthejob.com/mcp \
  --header "Authorization: Bearer flx_live_…"
mcp.json — Cursor, Claude Desktop, others
{
  "mcpServers": {
    "flex": {
      "type": "http",
      "url": "https://app.flexonthejob.com/mcp",
      "headers": { "Authorization": "Bearer flx_live_…" }
    }
  }
}

A good first prompt

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

Recipes

Thirteen jobs an agent can run on day one.

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.

PromptWhat it doesNeeds
reorder_below_minimumFinds 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_deliveryConfirms what arrived line by line, then receives it into stock in one all-or-nothing call. Optional purchaseOrderId or vendorName.ViewPurchaseOrders, ManagePurchaseOrders
cycle_countCompares counted quantities at one location with Flex and applies every correction as one batch. Optional locationId, counts as free text.ViewInventory, ManageInventory
restock_vansTops 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_jobsLists 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_summarySummarizes 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_followupLists 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_transfersWalks the transfer approval queue and approves or rejects each one as you decide.ManageTransfers
fulfill_ready_sales_ordersWalks 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_reportValues stock on hand by category and by location, at cost and at retail. Read-only. Optional categoryId, locationId.ViewInventory
end_of_day_summarySummarizes the day: jobs completed, invoices raised and paid, transfers delivered, new low-stock alerts. Read-only. Optional date.ViewJobs; more with ViewInvoices, ViewInventory
onboard_customerCreates 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_vendorUpdates 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.

Stability

Versioning and change policy.

/api/v1 is additive-only

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

Breaking changes get a new prefix

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.

Webhook and MCP contracts follow the same rule

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

Questions

For the person writing the code.

Is the API extra?

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.

Is anything only in the web app?

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.

Is there a sandbox?

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.

Is there an official SDK?

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.

Are there Zapier and Make integrations?

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

How do I get everything out?

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

Will an AI agent using MCP see data it shouldn't?

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.

What about webhooks for inventory?

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.

Where does the data live, and is there a SOC 2 report?

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.

Open the reference and try a call.

Start a free trial, create a key, paste it into the docs. Ten minutes to your first request.

API reference Start free trial