AI Agents · Ai

OpenAI + Acumatica Integration Patterns

OpenAI + Acumatica Integration Patterns is the work that defines the next phase of enterprise software. ERP systems hold the most valuable business data in the company —.

John Kihiu12 min read

Acumatica's contract-based REST API is a natural fit for OpenAI's function-calling: it's already a well-defined set of entities and actions (Sales Orders, Bills, Invoices, Stock Items) that map cleanly onto function schemas. The integration work isn't teaching the model to "understand" Acumatica — it's defining a tight set of functions, handling the API's session and auth model correctly, and putting a validation layer between what the model decides and what actually posts.

Authenticating against the contract-based API

Acumatica's REST endpoint uses cookie-based session auth by default: you POST credentials to /entity/auth/login, get a session cookie back, and reuse it for subsequent calls until you explicitly log out or the session expires. For a service account driving an agent, request an OAuth 2.0 bearer token instead (configured under a connected application in Acumatica) — it avoids the session-affinity issues you get with cookies behind a load balancer, and it's the credential type you want long-lived automation to hold rather than a login/logout pair per request.

Defining functions that match the endpoint shape

Each OpenAI function should map to one Acumatica entity action, not a vague "do something in Acumatica" catch-all. Narrow, typed functions get better tool-selection accuracy from the model than one big generic function with a free-text parameter. Put required fields, enums, and formats directly in the JSON schema — the model uses that schema to decide what to ask the user for before it ever calls the function.

PYTHON · FUNCTION SCHEMA FOR A SALES ORDER LOOKUP
tools = [{
    "type": "function",
    "function": {
        "name": "get_sales_order_status",
        "description": "Look up the status and total of a Sales Order in Acumatica by order number.",
        "parameters": {
            "type": "object",
            "properties": {
                "order_nbr": {"type": "string", "description": "Acumatica Sales Order number, e.g. SO004821"},
            },
            "required": ["order_nbr"],
        },
    },
}]

def get_sales_order_status(order_nbr: str) -> dict:
    resp = session.get(
        f"{BASE_URL}/entity/Default/23.200.001/SalesOrder/{order_nbr}",
        headers={"Authorization": f"Bearer {token}"},
    )
    resp.raise_for_status()
    body = resp.json()
    return {"status": body["Status"]["value"], "total": body["OrderTotal"]["value"]}

The tool-call loop

The pattern is standard OpenAI function-calling: send the user message plus the tool schemas, check whether the response contains tool_calls, execute the matching Python function, append the result as a tool role message, and call the model again so it can produce a natural-language answer grounded in the real API response. The important discipline is that the model never sees or invents the Acumatica data directly — it only sees what your function actually returned, which is what keeps it from hallucinating an order total that doesn't exist.

Never let the model construct write payloads unchecked

For read operations (status lookups, balance checks) letting the model call the function directly is low risk. For writes — creating a bill, releasing a payment, adjusting a quantity — validate the model's extracted parameters against business rules (amount limits, valid GL accounts, duplicate-check against existing records) in your own code before the PUT/POST goes to Acumatica. The model proposes the action; your code decides whether it's allowed to happen.

Mapping Acumatica field types to JSON schema

Acumatica's contract-based API wraps every field as {"value": ...}, and numeric/date fields serialize as strings in some endpoints and native types in others depending on the endpoint version. Don't hand the raw API response to the model — normalize it into a flat, clean dict in your function before returning it as the tool result. A model reasoning over {"OrderTotal": {"value": "1250.00"}} is more likely to misquote the number than one reasoning over {"total": 1250.00}; flattening is a five-line function that meaningfully improves reliability.

Handling Acumatica-specific failure modes

Two things bite integrations that work fine in testing: session/token expiry mid-conversation (handle a 401 by re-authenticating once and retrying the single failed call, not the whole conversation), and Acumatica's long-running-operation pattern on some PUT/POST endpoints, which returns a 202 with a location header rather than the final result immediately. Poll that location with backoff before returning a result to the model — if you return "submitted" as if it were "completed," the agent will confidently tell the user something is done before it actually is.

Acumatica behaviorIntegration handling
Cookie session vs OAuth bearerUse OAuth for service accounts, not login/logout per call
Fields wrapped as {"value": ...}Flatten before returning as tool result
202 + polling for long operationsPoll to completion before telling the model "done"
Custom fields per tenantKeep function schemas tenant-specific, don't hardcode across installs

Wrapping up

The OpenAI side of this integration is standard function-calling with well-scoped, typed tool definitions. The Acumatica side needs a service-account OAuth token, response flattening so the model reasons over clean numbers, and explicit polling for long-running writes. Keep read access loose and write access gated by your own validation layer — the API contract makes this straightforward once you stop treating the raw REST payload as something safe to hand the model directly.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.