Developers

API v3 · Current

OPDC API Documentation

The OPDC API exposes secure, opinionated endpoints for working with Sage 100 Contractor data. v3 is the current contract, and this guide is the whole of it: authentication, one read contract, one error shape, maintenance locking, and the seven endpoints that write to Sage. The field-level reference is available at the bottom of the page.

Section 1

Getting started#

Every endpoint is a POST that takes JSON and returns JSON. Paths are resource-first and plural, and the whole API is two shapes plus a list of nouns: one read envelope, one write envelope, and one error body. Learn those three and the rest is field names.

If you have used v2

What v3 changes, in one box

  • Status codes mean what they say. responseType is gone. A business failure is a 4xx or a 5xx, so res.ok is a correct check and every HTTP client, proxy and retry library already understands the answer.
  • One error body. application/problem+json (RFC 9457) on every status, with a stable machine-matchable code. v2 mixed JSON with bare text/plain sentences depending on the status.
  • One read envelope and one write envelope. Every query returns {asOf, page, data}; every write returns the document plus its journal, job costs and inventory movements under the same keys. v2 had six write shapes across six endpoints.
  • Idempotency keys on writes. Retry a timed-out post with the same key and get the original response back instead of a second invoice.
  • Dry run. Add ?dryRun=true to any write to execute the whole cascade against live data inside a transaction that is rolled back, and see exactly what would have posted.
  • Unknown fields are rejected. A misspelled field is a 400 naming it, not a silent drop that hands you the unfiltered table.

v3 ships beside v2, not instead of it./api/v2 stays available as a frozen compatibility surface and is not deleted, so no existing integration has to move to keep working. Switch this page to v2 with the selector at the top if that is the surface you are on.

Migration checklist

Move one read at a time

  1. Keep your existing /api/v2 client running while you add a v3 client beside it.
  2. Change the path to /api/v3/{entity}/query and use the v3 resource name.
  3. Translate page.count to page.totalCount; v3 page numbers start at 1.
  4. Parse non-2xx responses as the documented problem body and log X-Correlation-Id.
  5. Run both clients against the same filter and compare counts before switching traffic.

v2 is scheduled for removal in December 2026. New integrations should start on v3; existing integrations have time to migrate deliberately.

Base URL

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

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

Set your credentials

Put the host and API key in your shell once, then reuse them in the examples below. Keep the key in an environment variable or secret manager; never commit it to source control.

# macOS / Linux
export OPDC_HOST="https://{your-opdc-host}"
export OPDC_API_KEY="{your-api-key}"

# PowerShell
$env:OPDC_HOST = "https://{your-opdc-host}"
$env:OPDC_API_KEY = "{your-api-key}"

Headers

HeaderRequiredNotes
x-api-keyYesYour API key, a UUID. Missing, non-UUID and unrecognised keys all return 401 UNAUTHORIZED.
Content-TypeYesapplication/json
Idempotency-KeyWritesUp to 128 printable characters, unique per logical write. Required on /purchase-order-receipts, recommended on the other six. See section 5.
traceparentNoW3C trace context. Propagated to the on-premise service and into the server logs for the request. Most tracing SDKs send it for you.

v2's bespoke correlation headers are gone.x-context-id and x-integration-id are not part of v3. Send the W3C standard traceparent instead, which most HTTP clients and tracing SDKs already emit for you. Every response, including the ones that never reach a handler, carries X-Correlation-Id; quote that one token when you raise a ticket.

Your first call

The cheapest proof that your key works and that your Sage data is reachable is a one-row query. Any of the entities in section 2 will do.

{ "select": ["partCode", "partName"], "page": { "pageSize": 1 } }

Response

{
  "asOf": "2026-08-09T09:14:02Z",
  "page": { "pageNumber": 1, "pageSize": 1, "totalCount": 4182, "returnedCount": 1 },
  "data": [
    { "id": "3f9c...-uuid", "recordNumber": 4471,
      "partCode": "PIPE-001", "partName": "PVC Pipe 4\"" }
  ]
}

page.totalCount is the number of rows matching your filter, so a query with no filter tells you how large the entity is in one call.

Then add a real filter

Once the connection works, select only the fields you need and narrow the result with a filter. This example finds two part codes whose cost is at least 10, newest changes first.

{
  "select": ["partCode", "partName", "partCost"],
  "filters": {
    "and": [
      { "match": { "field": "partCode", "operator": "IN", "values": ["PIPE-001", "VALVE-200"] } },
      { "match": { "field": "partCost", "operator": ">=", "value": 10 } }
    ]
  },
  "orderBy": ["partCost DESC"],
  "page": { "pageNumber": 1, "pageSize": 25 },
  "lastUpdated": "2026-01-15"
}

Response

{
  "asOf": "2026-08-09T09:14:02Z",
  "page": { "pageNumber": 1, "pageSize": 25, "totalCount": 2, "returnedCount": 2 },
  "data": [
    { "id": "3f9c...-uuid", "recordNumber": 4471,
      "partCode": "PIPE-001", "partName": "PVC Pipe 4\"", "partCost": 12.50 },
    { "id": "7a21...-uuid", "recordNumber": 4498,
      "partCode": "VALVE-200", "partName": "Ball Valve 1\"", "partCost": 18.75 }
  ]
}

Use data for the returned records, returnedCount for this page, and totalCount for the full match count.

Utility routes are available under v3

Ping, and the maintenance lock in section 4, are operator tooling rather than data entities, but v3 exposes them so a new integration can use one base URL. Call POST /api/v3/utility/ping with the same API key to measure the round trip to your on-premise service. Existing v2 clients may continue using their original utility paths.

Section 2

Reading: one contract, 82 entities#

Every readable entity answers the same two operations with the same request body and the same envelope. Only the objects inside data differ. Learn the contract here and you have learned the whole read surface.

Endpoints

POST/api/v3/{entities}/query

POST/api/v3/{entities}/match

Plural, resource-first, verb last: /parts/query, /jobs/query, /purchase-orders/query, /vendors/match. v2 spelled these /query/part, singular noun, verb first. A mistyped entity is now a 404 naming it, rather than an empty result set that looks exactly like an empty result set.

Request body: every field

{
  "select": ["partCode", "partName", "partCost"],
  "filters": {
    "and": [
      { "match": { "field": "partCode", "operator": "IN", "values": ["PIPE-001", "VALVE-200"] } },
      { "match": { "field": "partCost", "operator": ">=", "value": 10 } }
    ]
  },
  "orderBy": ["partCost DESC"],
  "page": { "pageNumber": 1, "pageSize": 25 },
  "lastUpdated": "2026-01-15"
}
FieldBehaviour
selectFields to return. Omit for every field on the entity. Dotted paths (lines.extendedTotal) address child collections and are validated against the child model.
childrenChild collections to include, by name. Replaces v2’s withChildren boolean; the valid values are enumerated per entity, so an unknown one is a 400 that lists them.
filtersA recursive boolean tree of and, or and match nodes. Omit it to return everything.
orderByArray of "field ASC|DESC" clauses. The server always appends a unique tiebreak, so paging is stable even with no sort key.
pagepageNumber (1-indexed, default 1) and pageSize (default 100, maximum 1000). A pageSize above the maximum, or below 1, is rejected rather than clamped.
lastUpdatedYYYY-MM-DD. Rows whose modification date is at or after it, on the 7 entities that record one. 501 elsewhere.
createdSinceYYYY-MM-DD. Rows whose entered date is at or after it, on the 14 entities that record one. Finds new records; never finds edits to older ones.

Three v2 fields no longer exist and are rejected rather than ignored: withChildren (use children), offset (use page.pageNumber), and the legacy singular filter (use filters). selectFields is now select.

Response envelope

{
  "asOf": "2026-08-09T09:14:02Z",
  "page": { "pageNumber": 1, "pageSize": 25, "totalCount": 142, "returnedCount": 25 },
  "data": [
    { "id": "3f9c...-uuid", "recordNumber": 4471,
      "partCode": "PIPE-001", "partName": "PVC Pipe 4\"", "partCost": 12.50 }
  ]
  }

asOf is the server's clock at the moment the result set was produced, in UTC. Store it as the watermark for your next call: your own clock is not a safe watermark, and v2 gave you nowhere else to get one.

The v2 footgun is gone

Branch on the HTTP status. There is no responseType

v2 returned business failures as HTTP 200 with responseType: "ERROR" on reads, and HTTP 500 with the same envelope on writes, so a client that checked res.ok treated every business failure as a success on one side and as an infrastructure fault on the other. That page needed a full-width red warning to explain it.

v3 retires the field entirely. A 2xx means it worked. A 4xx means fix the request. A 5xx means the server or Sage failed. Every failure carries an RFC 9457 problem document, on every status, with the same keys.

The whole client

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

if (!res.ok) {
  // application/problem+json, on every status, with the same keys.
  const p = await res.json()
  throw new Error(`${p.title}: ${p.detail} [${p.correlationId}]`)
}

const body = await res.json()
return body.data          // reads. Writes return the envelope itself.

Unknown fields are rejected, not dropped

A typo like selectFeilds is a 400 naming the offending field in errors[]. In v2 it was silently dropped, which left select unset and returned every field on the entity, a misspelled filters returned the unfiltered table with a 200. Both are now loud.

Pagination

  • pageNumber is 1-indexed: page 1 is the first page and page 2 is the second. The executor's zero-based offset is an internal implementation detail.
  • totalCount is the total number of matching rows; returnedCount is how many are in data on this response. v2 had one field called count that meant the first, and every integrator had to be told so.
  • The default page size is 100 and the maximum is 1000. Both are reported back on every response, so you can see what you got without knowing the server's defaults. A pageSize above the maximum is rejected, never clamped, a clamped request returns a tenth of what you asked for and no way to know it. pageSize: 0 is a 400, not "everything".
  • Paging is stable without an orderBy. The server always appends a unique tiebreak to whatever you supply, so orderBy is a prefix of the real ordering rather than the whole of it. In v2, paging a large entity without a sort key could hand you the same row twice and never hand you another.

Child collections: ask for the ones you want

v2 had withChildren: true, which meant "all of them" and never said what "all of them" was, the published spec documented six collections while seventeen were being returned at runtime. children is a list, and the valid values are enumerated per entity in the contract, so an unknown one is a 400 that tells you what is valid.

// POST /api/v3/purchase-orders/query
{
  "select": ["orderNumber", "vendorNumber", "lines.extendedTotal"],
  "children": ["lines"],
  "filters": { "match": { "field": "jobNumber", "operator": "=", "value": 1450 } },
  "page": { "pageNumber": 1, "pageSize": 25 }
}
  • The alias is lines on every parent but one: assemblies uses parts. It was not renamed, because that would break every live caller for a cosmetic gain; it is enumerated instead, which fixes the real problem.
  • Children are fetched with one query per relation over the parent page's ids, so there is no N+1. There is also no bound: a page of 100 purchase orders pulls every line of all 100. Ask only for the collections you need.
  • Dotted paths in select are validated but do not yet narrow the child rows.lines.extendedTotal is checked against the child model, a typo, or a path naming a collection you did not ask for, is a 400, and then the child rows come back with every field on them regardless. The contract is ahead of the query executor on this one field, and it is stated here rather than left to be discovered.
  • Children cannot be filtered.

Incremental sync: check that your entity supports a watermark

Sage keeps no modification timestamp on most of its tables, so there is no single "give me everything that changed" filter that works across all 82 entities. There are two fields instead, and each is honoured only on the entities whose table carries the column it needs. Send one to an entity that has no such column and the request is rejected with a 501 (CHANGE_STAMP_UNSUPPORTED) naming the field, rather than quietly returning everything, which is what v2 did, on all 79 of its entities, while its documentation called the field "the one to build incremental syncs on". The two fields are independent: an entity can support one and reject the other.

lastUpdated

Answers "what changed", including edits.

Filters on the date Sage rewrites whenever the row is edited. These are the only entities that have one.

Supported on 7

/parts, /vendor-part-prices, /budgets, /cost-to-completes, /hours-to-completes, /proposals, /units-completes

createdSince

Answers "what is new". Never reports an edit.

Filters on the date the record was entered, which Sage writes once and never revises. This is what the transaction tables carry instead of a modification date.

Supported on 14

/ledger-transactions, /ap-invoices, /ar-invoices, /purchase-orders, /subcontracts, /change-orders, /job-costs, /payroll-records, /service-inventories, /service-contracts, /equipment, /equipment-costs, /inventory-allocations, /inventory-histories

  • Both fields are dates, not timestamps. Every column behind them is a Sage date with no time of day, so v3 types them as YYYY-MM-DD rather than inventing a time that has no meaning. The comparison is inclusive, deliberately: a watermark re-delivers a row rather than skipping one. Expect the last batch again, and use an idempotency key on the writes you make from it.
  • createdSince is not a substitute for lastUpdated. Sage never rewrites the entered date, so an invoice raised last week and corrected today does not come back. If you need corrections on those entities, re-read a trailing window rather than trusting a watermark.
  • Pair either field with orderBy and asOf: store the server's asOf from a completed run and send it as the next run's watermark.

Resolving names? Use match, not LIKE

For a human-entered name, a vendor off an invoice, a job from a spreadsheet, do not reach for LIKE '%...%'. Use POST /api/v3/{entities}/match, available on clients, vendors, jobs, parts and employees. It scores candidates by Levenshtein edit distance against the fields you name, normalising both sides first, and returns everything within maxDistance closest-first with its distance attached.

{
  "matchString": "Acme Corp",
  "matchFields": ["vendorName", "shortName"],
  "select": ["recordNumber", "vendorName"],
  "maxDistance": 3
}
{
  "asOf": "2026-08-09T09:14:02Z",
  "data": [
    { "recordNumber": 124, "vendorName": "ACME CORPORATION", "distance": 2 }
  ]
}

All four fields are required, select, matchFields, matchString and maxDistance, which must be 1 or greater. v2 enforced the same rules and documented none of them, so the first call an integrator copied out of the documentation failed. filters is accepted as an optional pre-filter to narrow the candidate set before scoring. There is no page block on the response: matching has to score every candidate before it can rank them, so maxDistance is the bound.

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 match.
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. The query builder normalises case before matching, so like and LIKE are both accepted. 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": 2.100 },
      { "jobNumber": 1044, "costCode": 3.100 }
    ]
  }
}

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

Where field names come from

Field names in select, orderBy, matchFields and every match.field are the JSON names OPDC publishes, not the underlying Sage column names: partCode, not the cryptic column it maps to. OPDC translates them for you in both directions.

A name that does not exist on the entity is a 400 naming it in errors[], in v2 it was a 200 carrying an error envelope, which on a read is indistinguishable from an empty result. The full per-entity field lists live in the reference at the bottom of this page.

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

Most numeric codes in Sage are fixed by the product, and v3 publishes them as enumerations with their meanings attached. 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/cost-types/querySage 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/cost-codes/queryEntirely company-defined, and decimal: 2.100 and 3.100 are ordinary values.
sourceNumber/transaction-sources/queryThe 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 shape, on every status#

Every data-read and write failure in v3 is application/problem+json (RFC 9457), on every route and every status code. v2 mixed JSON on some statuses with bare text/plain sentences on others, so a client that called JSON.parse unconditionally threw on the error path and lost the message. One shape, always parseable. The utility operations share their implementation with v2, but their v3 routes use this same problem serializer. Existing v2 clients retain the legacy body.

The problem document

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
X-Error-Code: PERIOD_CLOSED
X-Correlation-Id: 7d1c8e40-...

{
  "type":     "https://twinn.engineering/errors/period-closed",
  "title":    "Posting period is closed",
  "status":   422,
  "detail":   "transactionDate 2026-11-04 resolves to period 11/2026; the open period is 3/2026",
  "instance": "/api/v3/ledger-transactions",
  "errors":   [ { "field": "transactionDate", "code": "PERIOD_CLOSED" } ],
  "correlationId": "7d1c8e40-..."
}
FieldMeaning
typeA URI identifying the problem type. Stable and machine-matchable; the last segment is the error code.
titleShort, stable, human-readable summary. Does not vary between occurrences.
statusThe HTTP status code, repeated in the body for clients that lose it.
detailHuman-readable detail for this occurrence. Names specifics, the date, the account, the two figures that did not agree.
instanceThe request path.
errors[]Present when specific input fields are to blame. Each entry is a JSON path (lines[2].debitAmount) plus its own code.
correlationIdThe X-Correlation-Id of the request. Server errors are redacted before they reach you; this is the handle to the unredacted record. Quote it on a ticket.

The stable machine-readable part is the code at the end of type, which is also sent as the X-Error-Code response header. Codes are append-only: a code's spelling and meaning never change once published, and a failure mode that stops existing keeps its code reserved rather than having it reused. Branch on the code; do not pattern-match detail.

Statuses, and what to retry

StatusWhenRetry?
200It worked. Reads return the query envelope; writes return the write envelope.n/a
400The request is malformed: unreadable body, an unknown or misspelled field, a required field missing, a value outside its rule, an invalid Idempotency-Key.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.
404An unknown entity on a read, or a referenced record that does not exist on a write. New in v3: v2 had no 404 anywhere.No.
409An Idempotency-Key reused for a different request, a request with that key still in flight, or a transaction number already posted with different lines.Only IDEMPOTENCY_KEY_IN_FLIGHT, after Retry-After.
422Well-formed but not applicable: a closed posting period, an unbalanced journal, amounts that do not agree with their lines, a column this Sage build does not have.No. The payload or the accounting state needs fixing.
423The database is locked for maintenance. The body names the holder.Yes. Honour Retry-After.
429Rate limited. Reserved by the contract whether or not limits are enforced on your instance today.Yes. Honour Retry-After.
500An unexpected server-side failure. The message is redacted; the detail is in the server log under your correlationId.Not blindly. Quote correlationId.
501lastUpdated or createdSince on an entity that records no such date, or a posting path deliberately not wired up. The body names the field.No.
502Sage itself refused: a constraint, a deadlock, a permission, a dropped connection. The gateway is fine; the database said no.Yes, with backoff. No Retry-After, there is no honest number.
503The service is switched off for this API key, with the operator’s reason in the body.Retry-After 300, but contact support.
504The on-premise service did not answer in time. The outcome of a write is unknown.Yes, with the same Idempotency-Key, never without one.

The full error code table

Retry policy in one paragraph

Retry 423, 502, 503 and 504 with backoff. Three of them carry Retry-After, so a client library can honour it without reading this page; 502 deliberately does not, because there is no honest number, a deadlock clears in milliseconds and a revoked permission never clears. Never retry 400, 401, 404, 422 or 501 unchanged: the request itself is what needs fixing. On a write, retry with the same Idempotency-Key, that is the whole reason the header exists, and a 504 means the outcome is genuinely unknown.

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 an operator gets that, and it is why your integration will occasionally see a 423. The lock is set through the v3 utility routes, and it applies to every /api/v3 route.

The full flow

  1. 1

    bill locks the database for maintenance

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

    Every v3 call is rejected while the lock holds

    Not just /parts/query shown here, every read and every write on /api/v3 fails the same way until the lock clears.

    POST /api/v3/parts/query
    -> 423{ "type": ".../maintenance-lock", "title": "Locked for maintenance", "detail": "Locked for maintenance by bill" } [problem+json, Retry-After: 60]
  3. 3

    bill releases the lock when he's done

    POST /api/v3/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/v3/utility/un-lock
    { "userName": "ann" }
    -> 409locked by different user: bill (use override=true to force) [problem+json, same v3 error contract]

    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.

v3 tells your client how long to wait

The 423 is a problem document with MAINTENANCE_LOCK and a Retry-After header, so a standard retry library backs off correctly with no code from you. v2 documented the back-off in prose only.

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.

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, honour Retry-After, 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#

Seven endpoints write to Sage. Each 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. All seven return the same envelope, all seven accept an idempotency key, and all seven can be dry-run.

One write envelope, for all seven

v2 produced six different response shapes for the same five artefacts across six endpoints: the document was sometimes top-level and sometimes named, job costs were jobCosts[] on two endpoints and jobCost on another, and service inventory returned its ledger transaction twice. v3 returns this, every time:

HTTP/1.1 200 OK
Idempotency-Status: applied

{
  "document":          { ... },   // the document itself, with its lines
  "ledgerTransaction": { ... },   // absent when the document did not post
  "jobCosts":          [ ... ],   // always an array, empty when none
  "inventory":         [ ... ],   // always an array, empty when nothing moved
  "dryRun":            false,
  "period":            3,
  "year":              2026
}
  • Arrays are always arrays, and always present, empty rather than missing when the cascade produced nothing.
  • ledgerTransaction is absent, not null, when the document did not post to the general ledger.
  • period and year are always reported, because the server resolved them and you have no other way to learn where the document landed. (Service inventory is the one exception: a stored-only record posts nothing, so it resolves no period.)
  • The status is 200, not 201: there is no Location to send you, not every write creates (a receipt updates), and an idempotent replay is not a creation.

New in v3

Idempotency-Key: retry a write without wondering whether it committed

A 504 is a documented, retryable outcome, and without a key a caller cannot tell whether Sage committed. Send a unique key per logical write and the server stores it with its response for 24 hours, replaying that response on any repeat.

curl -X POST https://{your-opdc-host}/api/v3/ledger-transactions \
  -H "x-api-key: {your-api-key}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: {your-unique-key}" \
  -d '{
  "transactionNumber": "JE-4401",
  "transactionDate": "2026-03-16",
  "enteredDate": "2026-03-16",
  "description": "March accrual - subcontractor retainage",
  "sourceNumber": 3,
  "lines": [
    { "lineNumber": 1, "ledgerAccount": 5025, "debitAmount": 4500.00 },
    { "lineNumber": 2, "ledgerAccount": 2100, "creditAmount": 4500.00 }
  ]
}'
  • The key is scoped to your API key. Two integrations at one customer that both mint keys from a natural identifier cannot collide with each other.
  • The route and the body are part of the fingerprint, not the key. The same key sent with a different body, or to a different endpoint, is a 409 IDEMPOTENCY_KEY_REUSED rather than a wrong-shaped replay. Re-serialising the same payload with different whitespace or key order is the same request.
  • What is stored is the response that was sent, if and only if the request reached Sage. A 422 or a 502 is stored too, so a caller who corrects the body must use a new key. Nothing decided before dispatch, an unknown field, a validation failure, burns the key, so the corrected retry just runs.
  • Replays are labelled. Idempotency-Status: applied on the first write, replayed when the body is a recording of an earlier response and nothing was written again, and unavailable when the store could not answer and the write went ahead un-deduplicated. That last one is told to you rather than only logged.
  • A concurrent duplicate is refused, not executed twice. 409 IDEMPOTENCY_KEY_IN_FLIGHT with a short Retry-After.
  • A 504 under a key is recorded as unknown, and replayed as unknown. Retrying does not hand you a second write. If the on-premise service answers later, that answer is recorded against the key and the next retry returns it.

It is required on /purchase-order-receipts and recommended everywhere else. A receipt's updateQuantity is added to what the line has already received, and nothing on the Sage side refuses a second identical receipt, so accepting a key-less receipt would publish a promise of replay safety that endpoint cannot keep. A request without one is refused with 400 IDEMPOTENCY_KEY_INVALID.

?dryRun=true: see what a post would do, against real data

The document is validated, the whole cascade executes against your live Sage data inside one transaction, the journal, the balance propagation, the job costs, the inventory movement, and then the transaction is rolled back. You get the same result body a real post would have returned, including the amounts and the accounts it would have touched, with "dryRun": true on it so a stored body can never be mistaken for a committed write.

Every identifier in a dry-run response is provisional and will never exist. recordNumber, id, the ledger transaction's record number: all real inside the transaction, all gone after it. Do not store them and do not use them to correlate a later real post.

Two things are still consumed. Ledger reference numbers come from a database sequence and identity columns from the tables themselves, and neither is transactional, a rolled-back transaction does not give them back, so a dry run leaves permanent gaps exactly as a genuinely failed post does. If gapless numbering matters to your auditors, do not dry-run in bulk against production. An Idempotency-Key sent with a dry run is ignored rather than rejected, and is never burned.

The posting period

By default 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. Three endpoints (/ledger-transactions, /ap-invoices, /inventory-allocations) also accept an explicit period and year pair and honour it when it is valid, rejecting it with a 422 when it is not. In v2 those fields were accepted, documented as ignored, and ignored.

A post that resolves to a period later than the open one is rejected (422 PERIOD_CLOSED), with a message naming both. A post that resolves to an earlier period is accepted and lands there, so a stale document date puts the entry somewhere you may not intend. Whichever period was used comes back on the response, every time.

Each card has its own link. Expand them all before using your browser's find, since the gotchas are the part worth searching.

Section 6

Full API reference#

This is the v3 contract itself, not a description of it. The document below is OpenAPI 3.1 compiled from the TypeSpec the server and the generated clients are both built from, so what it says is what the API does. The guide above covers the 82 query entities, seven writes, and the problem shape for each status. This reference includes the complete field and type definitions.

The complete v3 contract is loaded here. Individual domain pages load only their own reference for faster navigation.

The field-level reference for all 93 routes. Expand an entity's model to see its available properties and types. Those property names are used in select, orderBy, matchFields and every match.field.

Use it as an index rather than reading it front to back. The 82 /query entries all share the contract from section 2, and every operation's failure responses are the same twelve from section 3. Those responses are declared once and referenced, so a status on one route has the same meaning on every route.

Loading the reference domains…

Something here does not match what you see?

This guide is maintained by hand alongside the generated reference, so if a field, a status value or a cascade does not behave the way it is described here, that is worth reporting rather than working around. Tell us what you called and what came back.

Contact us