Developers

OPDC API Documentation

The OPDC API exposes secure, opinionated endpoints for working with Sage 100 Contractor data. This guide covers the parts worth reading once: authentication, the shared query contract, error handling, maintenance locking, and the write endpoints. The exhaustive, field-level reference lives at the bottom of the page.

Section 1

Getting started

Every endpoint is a POST that takes a JSON body and returns JSON. There are no path or query parameters. The entity you are working with is part of the URL, and everything else goes in the body.

Base URL

https://{your-opdc-host}/api/v2

Your host is issued during onboarding. Endpoint paths in this guide are relative to this base URL.

Headers

HeaderRequiredNotes
x-api-keyYes Your API key, a UUID. Missing, non-UUID, or unrecognised keys all return 401.
Content-TypeYesapplication/json
x-context-idNo Correlation UUID attached to traces and audit records. Pass your own request or session identifier. A value that is not a valid UUID returns 400.
x-integration-idNo Identifies the calling integration, also a UUID. A malformed value returns 400.

Verify your key

/utility/ping is the cheapest proof that your key works and that the on-premise service is reachable. It sends a no-op message to your server and reports the round-trip time.

curl -X POST https://{your-opdc-host}/api/v2/utility/ping \
  -H "x-api-key: {your-api-key}" \
  -H "Content-Type: application/json" \
  -d '{}'

Response

{
  "status": "ok",
  "latencyMs": 148,
  "response": { ... }        // acknowledgement from your on-premise service
}

latencyMs is the full round trip out to your on-premise service and back, which makes it a useful health check before starting a batch. A 504 here means the on-premise service is down or unreachable, not that your key is bad.

Section 2

Querying: read this once

There are 79 /query/* endpoints in the reference below, and they are all the same endpoint with a different noun. Every one of them accepts the identical request body and returns the identical envelope. Only the objects inside response differ. Learn the contract here and you have learned all 79.

Endpoint

POST/api/v2/query/{entity}

For example /query/part, /query/job, /query/vendor, /query/purchase-order. The full list of entities is in the reference at the bottom of the page.

Request body: every field

{
  "selectFields": ["partNumber", "description", "unitCost"],
  "withChildren": false,
  "filters": {
    "and": [
      { "match": { "field": "partNumber", "operator": "in", "values": ["PIPE-001", "VALVE-200"] } },
      { "match": { "field": "unitCost", "operator": ">=", "value": 10 } }
    ]
  },
  "orderBy": ["unitCost DESC"],
  "page": { "pageNumber": 0, "pageSize": 25 },
  "lastUpdated": "2026-01-15T10:30:00Z"
}
FieldBehaviour
selectFieldsFields to return. Omit it, or leave it empty, and you get every field on the entity.
withChildren Include the entity's child records (invoice lines, PO lines, and so on). Defaults to false. Leave it off unless you need the children; it is a materially heavier query.
filters A recursive boolean tree of and, or, and match nodes. Omit it to return everything.
orderBy Array of sort clauses, each a field name plus optional ASC/DESC.
pagepageNumber and pageSize, or offset as an alternative to pageNumber.
lastUpdated ISO 8601 timestamp. Returns only rows modified since then, so this is the field to build incremental syncs on.

A legacy single-condition filter (singular) field is still accepted for backwards compatibility. Use filters for new work.

Response envelope

{
  "message": "",
  "responseType": "SUCCESS",
  "page": { "pageNumber": 0, "pageSize": 25, "offset": 0, "count": 142 },
  "response": [ { "partNumber": "PIPE-001", "description": "PVC Pipe 4\"", "unitCost": 12.50 } ]
}

Read this before you write a client

Branch on responseType, never on the HTTP status alone

A query that fails on the Sage side (an unknown field name, a SQL error, a script failure, an unbalanced journal) comes back as HTTP 200 with responseType: "ERROR" and the root cause in message. The HTTP layer only reports transport and auth problems; business outcomes live in the body.

A client that checks res.ok and moves on will silently treat every business failure as a success. On a read, that means "your filter referenced a field that does not exist" looks exactly like "there are no matching rows".

Do this

const res  = await fetch(url, { method: "POST", headers, body })
const body = await res.json()

if (body.responseType !== "SUCCESS") {
  // The real failure is here, on a 200 as often as on a 500.
  throw new Error(body.message)
}
return body.response

Write endpoints have the same trap with the status inverted. There, a Sage-side failure arrives as HTTP 500 carrying the same JSON envelope with responseType: "ERROR". It is not an infrastructure fault, and retrying it unchanged will not help, so read message. Checking responseType first is correct on both reads and writes; checking the status is correct on neither.

Unknown request fields are ignored, not rejected

The request body is parsed leniently, and a field the API does not recognise is dropped silently. So a typo like selectFeilds does not error. It just leaves selectFields unset and returns every field on the entity. A misspelled filters behaves the same way: you get an unfiltered result set, not a 400. Diff your payload against the reference when a query returns suspiciously much.

Pagination

  • pageNumber is 0-indexed. The first page is 0, not 1.
  • page.count in the response is the total number of matching rows, not the length of the page you were handed. Use it to decide whether to fetch another page; use response.length if you want the size of the current batch.
  • page is an object on both the request and the response, never a bare number.

Filter operators

OperatorDescriptionValue keyNotes
=Equal tovalueExact match.
!=Not equal tovalueCompiles to SQL <>.
>Greater thanvalueNumeric or date fields.
<Less thanvalueNumeric or date fields.
>=Greater than or equalvalueInclusive lower bound.
<=Less than or equalvalueInclusive upper bound.
LIKEPattern matchvalueUse % as the wildcard. For human-entered names, prefer the match endpoints below.
NOT LIKENegated pattern matchvalueExcludes rows matching the pattern.
INMatches any value in a listvaluesTakes the values array, not value.
inPairsMatches any of a set of composite keysfields + pairsDifferent shape; see the example below.

Operators are case-insensitive.like, LIKE, in and IN are all accepted, because the query builder normalises the case before matching. Uppercase is a convention here, not a requirement.

IN takes values (an array) instead of value. Every other operator except inPairs takes a single field plus value.

inPairs: composite-key lookup

inPairs is the one operator with a different shape: it takes fields and pairs instead of field and value. Use it to fetch a set of rows by composite key in one round trip, instead of N single-key queries. Each object in pairs must have a key for every name listed in fields.

{
  "match": {
    "operator": "inPairs",
    "fields": ["jobNumber", "costCode"],
    "pairs": [
      { "jobNumber": 1021, "costCode": "01-100" },
      { "jobNumber": 1044, "costCode": "02-200" }
    ]
  }
}

Nesting and / or

and and or nodes hold arrays of further nodes, so they nest to any depth. A leaf node is a match.

{
  "filters": {
    "and": [
      { "match": { "field": "jobNumber", "operator": "=", "value": 1021 } },
      { "or": [
          { "match": { "field": "costType", "operator": "=", "value": 1 } },
          { "match": { "field": "costType", "operator": "=", "value": 2 } }
        ]
      }
    ]
  }
}

Resolving names? Use the match endpoints, not LIKE

If you have a human-entered name, say a vendor off an invoice or a job from a spreadsheet, do not reach for LIKE '%...%'. Use POST /api/v2/match/{client|vendor|job|part|employee}. It scores candidates by Levenshtein edit distance against the fields you name, normalising both sides first (lowercased, non-alphanumeric characters stripped), and returns matches within maxDistance sorted closest-first with their distance attached.

POST /api/v2/match/vendor

{
  "matchString": "Acme Corp",
  "matchFields": ["name", "shortName"],
  "selectFields": ["recordNumber", "name"],
  "maxDistance": 3
}

{
  "message": "",
  "responseType": "SUCCESS",
  "response": [
    { "recordNumber": 124, "name": "ACME CORPORATION", "distance": 2 }
  ]
}

filters is accepted here too, as an optional pre-filter to narrow the candidate set before scoring.

Where field names come from

Field names in selectFields, orderBy, and every match.field are the JSON names OPDC exposes, the property names listed under each entity's model in the full API reference at the bottom of this page. They are not the underlying Sage column names: partNumber, not the cryptic Sage column it maps to. OPDC translates them for you on the way in and out.

A field name that does not exist on the entity is a Sage-side failure: HTTP 200 with responseType: "ERROR" and the offending name in message.

Some codes are per-company: read them, do not hardcode them

Most numeric codes in Sage are fixed by the product, and the reference names them. Three are not: they are configured per company, so the values in your customer's install may not match anyone else's. Query them rather than shipping constants.

FieldRead it fromNotes
costType/query/cost-typeSage presets 1 Material, 2 Labor, 3 Equipment, 4 Subcontract, 5 Other. Values 6 to 9 are defined by each company and differ between installs.
costCode/query/cost-codeEntirely company-defined.
sourceNumber/query/transaction-sourceThe GL transaction source. Sage creates a standard set when the company is built, and sites can add their own above it.

Section 3

Errors: one table for the whole API

Every endpoint in the reference repeats the same handful of responses. Here they are once. These are the only statuses the API produces, and they mean the same thing on every route.

Error bodies are plain text, not JSON

The 200 envelope and the write endpoints' 500 are application/json. Everything else (400, 401, 409, 423, 503, 504) is text/plain, a bare sentence like Locked for maintenance by bill. A client that unconditionally runs JSON.parse on the response body will throw on the error path and mask the real message. Check the status first, and read those bodies as text.

StatusWhenRetry?
200 · SUCCESSThe call succeeded. Your data is in response.n/a
200 · ERRORReads and utility calls: the Sage side failed (unknown field, SQL error, script failure). message holds the root cause.Depends on the message. Fix the payload, or retry if it names a transient condition.
200 · NO_RESPONSEAccepted, but nothing was returned.No, the call was processed.
400Malformed or empty JSON body; an x-context-id or x-integration-id that is not a valid UUID; a missing userName on lock or un-lock.No, fix the request.
401No x-api-key header, a key that is not a UUID, or a key the server does not recognise.No, fix the credential.
409Un-lock attempted while a different user holds the lock.Only with override: true, and only deliberately.
423Any query, match, or write while the database is locked for maintenance. The body names the holder.Yes, back off and retry. Clears when the operator un-locks.
500Internal error. On write endpoints this also carries a JSON envelope with responseType: ERROR when the Sage-side post failed; read message.Not blindly. If responseType is ERROR, the payload or the accounting state needs fixing.
503The service is disabled for this API key. The body carries the reason.No, contact support.
504The round trip to your on-premise service failed or timed out, usually because the service is down, restarting, or slow.Yes, back off and retry.

Retry policy in one paragraph

Retry 504 and 423 with backoff. Both mean "the on-premise side is not available right now", and both clear without you changing anything. Never retry 400, 401 or 503 since a malformed body, a bad key, and a disabled key will all fail identically forever. A 200 or 500 carrying responseType: "ERROR" depends entirely on the message: a closed accounting period or an unbalanced journal needs a corrected payload, a transient database problem does not. Log the message; do not blind-retry writes.

Section 4

Maintenance locking

Some Sage maintenance, closing and rotating an accounting period for instance, needs every other connection to the database gone. Locking is how you get that, and it is why your integration will occasionally see a 423.

The full flow

  1. 1

    bill locks the database for maintenance

    POST /api/v2/utility/lock
    { "userName": "bill" }
    -> 200{ "status": "locked", "lockUser": "bill", "response": { ... } }
  2. 2

    Every other call is rejected while the lock holds

    Not just /query/part shown here — every /query/*, /match/*, /insert/*, /update/*, and /payroll call fails the same way until the lock clears.

    POST /api/v2/query/part
    -> 423Locked for maintenance by bill [text/plain]
  3. 3

    bill releases the lock when he's done

    POST /api/v2/utility/un-lock
    { "userName": "bill" }
    -> 200{ "status": "unlocked", "response": { ... } }
  4. 4

    A different user can't release bill's lock by accident

    ann is not the holder, so her un-lock is rejected. She has to explicitly opt in to override it.

    POST /api/v2/utility/un-lock
    { "userName": "ann" }
    -> 409locked by different user: bill (use override=true to force) [text/plain]

    Retry with { "userName": "ann", "override": true } to force it through.

What a lock actually does

Locking disconnects every database connection the on-premise service is holding, so Sage can do exclusive work. It is a deliberate, operator-initiated action, not something the API does on its own.

Lock state is persistent

The lock is stored server-side, not held in memory, so it survives a restart of the on-premise service. A lock that is never released stays in force, so always pair a lock with an un-lock.

Utility routes stay open

While locked, everything under /api/v2/utility/ keeps working, so you can still ping and you can still un-lock. Every /query/*, /match/*, /insert/*, /update/* and /payroll call returns 423.

Ownership is per API key

A lock is recorded against the API key that set it, together with the userName that took it out. Un-locking validates that holder and returns 409 if someone else has it, unless you pass "override": true. userName is required on both calls, and omitting it is a 400.

Integrators: back off on 423, do not fail the batch

A 423 is not an error in your payload. It means an accountant is mid-maintenance and will be done in minutes. Treat it exactly like a 504: pause, retry with backoff, and resume where you left off. Aborting a payroll or invoice batch because of a lock turns a two-minute maintenance window into a manual re-run.

Section 5

Writes

Six endpoints write to Sage. Each one is a single transaction that cascades. You post a document, and OPDC creates the header, the lines, and every derived record Sage expects: ledger transactions, balance propagation, job costs, inventory movements. Expand a card for its request shape and its gotchas. Auth and error handling are the same as everywhere else; see section 1 and section 3.

The posting period is resolved server-side: the header period and year are ignored

This applies to every write endpoint, so it is stated once here. Some request bodies still accept period and year at the top level, but those do not control anything. OPDC derives the accounting period from a date field on the document (which one depends on the endpoint, and each card below names it) under your Sage ledger-setup policy: either the current open period, or the period the document's date falls in.

The exception is job costs. A period and year set on an entry inside jobCosts is used exactly as you send it, with no range check and no comparison against the open period. Omit them and the job cost inherits the period resolved for the document, which is almost always what you want. Set them only if you know why.

A post that resolves to a period later than the open one is rejected, with a message naming both. A post that resolves to an earlier period is accepted and will land in that earlier period, so a stale document date puts the entry somewhere you may not intend. Check the date you send rather than relying on the API to catch it. Note this is stricter than Sage in one direction and looser in the other: Sage itself will generally accept postings anywhere in the open fiscal year.

Section 6

Full API reference

The exhaustive, field-level index of every endpoint, generated from the OpenAPI spec. Its job is the detail the guide above deliberately leaves out. This is where the per-entity field lists live. Expand an entity's model to see every property it exposes, with types. Those property names are exactly what you put in selectFields, orderBy, and match.field.

Read it as an index rather than front to back. The /query/* entries all share the contract from section 2, and the response entries on every route are the statuses from section 3, so there is no need to read either one again on each of the 94 routes.

Loading the API reference…